codex-work-receipt 0.4.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 +19 -0
- package/README.en.md +6 -4
- package/README.md +6 -4
- package/docs/cli.en.md +7 -3
- package/docs/cli.md +7 -3
- package/docs/mobile-import.en.md +6 -4
- package/docs/mobile-import.md +6 -4
- package/docs/privacy.en.md +2 -0
- package/docs/privacy.md +2 -0
- package/package.json +2 -1
- package/src/cli.mjs +13 -10
- package/src/core/canonical.mjs +27 -0
- package/src/core/fact-buckets.mjs +205 -0
- package/src/core/fact-identity.mjs +39 -0
- package/src/core/metrics.mjs +14 -20
- package/src/core/presentation.mjs +14 -6
- package/src/core/qr-payload.mjs +208 -19
- package/src/core/range.mjs +24 -5
- package/src/core/receipt-record.mjs +37 -11
- package/src/core/source-revision.mjs +17 -0
- package/src/parsers/codex.mjs +27 -7
- package/src/renderers/html.mjs +193 -47
- package/templates/README.md +3 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.5.0
|
|
14
|
+
|
|
15
|
+
- 新增网页版“保存完整长图”功能。
|
|
16
|
+
- 高清 PNG 包含主小票、小程序码和数据二维码导入联。
|
|
17
|
+
- 导出图片自动使用当前主题,并排除主题按钮、网页背景和其他页面元素。
|
|
18
|
+
- 网页主题与小程序统一为经典白票、复古粉票和夜班绿票。
|
|
19
|
+
- 小票顶部明确展示统计日期范围,默认文件名同步携带日期范围或会话短编号。
|
|
20
|
+
- 图片在浏览器本地渲染,不上传小票数据。
|
|
21
|
+
|
|
3
22
|
## 0.4.0
|
|
4
23
|
|
|
5
24
|
- 新增无参数命令行范围选择器。
|
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:
|
|
@@ -36,7 +38,7 @@ Summarize all Codex activity from today:
|
|
|
36
38
|
npx codex-work-receipt@latest --today --lang en
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
HTML and structured data are written to `./codex-work-receipt-output/` by default. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
|
|
41
|
+
HTML and structured data are written to `./codex-work-receipt-output/` by default. The generated page can save a high-resolution PNG containing the full receipt and QR import stub. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
|
|
40
42
|
|
|
41
43
|
## Ask Codex directly
|
|
42
44
|
|
|
@@ -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
|
|
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
|
|
|
@@ -70,12 +72,12 @@ The desktop page generates a privacy-safe data QR code. The companion WeChat min
|
|
|
70
72
|
<p align="center">
|
|
71
73
|
<a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
|
|
72
74
|
<a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
|
|
73
|
-
<sub>
|
|
75
|
+
<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>
|
|
74
76
|
</p>
|
|
75
77
|
|
|
76
78
|
## License
|
|
77
79
|
|
|
78
|
-
The desktop source is licensed under [GNU GPL v3](LICENSE) (`GPL-3.0-only`). This
|
|
80
|
+
The desktop source is licensed under [GNU GPL v3](LICENSE) (`GPL-3.0-only`). This is an unofficial community project and is not affiliated with OpenAI.
|
|
79
81
|
|
|
80
82
|
---
|
|
81
83
|
|
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 日 / 本周 / 指定会话”:
|
|
@@ -36,7 +38,7 @@ npx codex-work-receipt@latest
|
|
|
36
38
|
npx codex-work-receipt@latest --today
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
网页和结构数据默认保存在 `./codex-work-receipt-output
|
|
41
|
+
网页和结构数据默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键保存包含主小票和二维码导入联的高清长图。也可以继续使用 `--latest`、`--today` 等非交互参数,详见 [CLI 使用文档](docs/cli.md)。
|
|
40
42
|
|
|
41
43
|
## 直接跟 Codex 说
|
|
42
44
|
|
|
@@ -54,7 +56,7 @@ Codex 会选择统计范围和主题、执行命令并打开小票。详见 [Cod
|
|
|
54
56
|
|
|
55
57
|
## 从电脑到手机
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
桌面网页会生成一张或多张脱敏数据二维码。配套微信小程序支持分片扫码、私人历史、主题切换和保存图片。详见 [手机扫码导入](docs/mobile-import.md)。
|
|
58
60
|
|
|
59
61
|
## 文档
|
|
60
62
|
|
|
@@ -70,12 +72,12 @@ Codex 会选择统计范围和主题、执行命令并打开小票。详见 [Cod
|
|
|
70
72
|
<p align="center">
|
|
71
73
|
<a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
|
|
72
74
|
<a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
|
|
73
|
-
<sub
|
|
75
|
+
<sub>让个人开发者也可以以很低的成本、稳定地接入前沿 AI 模型的 API,借助模型能力快速构建自己的产品。</sub>
|
|
74
76
|
</p>
|
|
75
77
|
|
|
76
78
|
## License
|
|
77
79
|
|
|
78
|
-
电脑端源码采用 [GNU GPL v3](LICENSE)(`GPL-3.0-only
|
|
80
|
+
电脑端源码采用 [GNU GPL v3](LICENSE)(`GPL-3.0-only`)。本项目是非 OpenAI 官方社区工具。
|
|
79
81
|
|
|
80
82
|
---
|
|
81
83
|
|
package/docs/cli.en.md
CHANGED
|
@@ -73,10 +73,14 @@ Default output:
|
|
|
73
73
|
|
|
74
74
|
```text
|
|
75
75
|
./codex-work-receipt-output/
|
|
76
|
-
├── codex-receipt-
|
|
77
|
-
└── codex-receipt-
|
|
76
|
+
├── codex-receipt-today-2026-07-18.html
|
|
77
|
+
└── codex-receipt-today-2026-07-18.json
|
|
78
78
|
```
|
|
79
79
|
|
|
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
|
+
|
|
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
|
+
|
|
80
84
|
Set a timezone and output path:
|
|
81
85
|
|
|
82
86
|
```bash
|
|
@@ -130,4 +134,4 @@ See the [data schema and QR protocol](data-schema.en.md).
|
|
|
130
134
|
- Codex is the only supported data source; Cursor and WorkBuddy are planned
|
|
131
135
|
- Changed-file and line counts are intentionally omitted until they can be measured consistently
|
|
132
136
|
- Calendar ranges filter individual events by local date and calculate a Token delta for each session
|
|
133
|
-
-
|
|
137
|
+
- Desktop PNG export is rendered locally in the browser and does not upload receipt data
|
package/docs/cli.md
CHANGED
|
@@ -73,10 +73,14 @@ npx codex-work-receipt@latest --latest --theme diner
|
|
|
73
73
|
|
|
74
74
|
```text
|
|
75
75
|
./codex-work-receipt-output/
|
|
76
|
-
├── codex-receipt-
|
|
77
|
-
└── codex-receipt-
|
|
76
|
+
├── codex-receipt-today-2026-07-18.html
|
|
77
|
+
└── codex-receipt-today-2026-07-18.json
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
默认文件名会携带统计日期范围;最近会话和指定会话则携带短编号,避免不同日期或会话的小票互相覆盖。
|
|
81
|
+
|
|
82
|
+
打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。cwr2 数据超过单个二维码的安全容量时,导入联会自动展示多张带序号的分片二维码,需在小程序中全部扫完。
|
|
83
|
+
|
|
80
84
|
指定时区和输出路径:
|
|
81
85
|
|
|
82
86
|
```bash
|
|
@@ -130,4 +134,4 @@ npx codex-work-receipt@latest --help
|
|
|
130
134
|
- 当前只支持 Codex;Cursor、WorkBuddy 等数据源仍在规划中
|
|
131
135
|
- 暂不统计修改文件数和代码行数,避免不同工具调用方式产生误导
|
|
132
136
|
- 时间范围按每条事件的本地日期筛选,并分别计算每个会话的 Token 区间增量
|
|
133
|
-
-
|
|
137
|
+
- 桌面网页导出 PNG 使用浏览器本地渲染,不上传小票数据
|
package/docs/mobile-import.en.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
package/docs/mobile-import.md
CHANGED
|
@@ -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
|
-
|
|
5
|
+
桌面网页会同时展示固定小程序码和当前小票的数据二维码。配套小程序会先在手机端完成协议和隐私字段校验,再把私人小票保存到用户的匿名账号数据库:
|
|
6
6
|
|
|
7
7
|
1. 微信扫描固定小程序码并打开配套小程序。
|
|
8
8
|
2. 点击“从电脑导入”。
|
|
9
9
|
3. 扫描网页旁边的数据二维码。
|
|
10
|
-
4. 小程序校验 `cwr1`
|
|
10
|
+
4. 小程序校验 `cwr1`、`cwr2` 或 `cwr2p` 分片前缀、checksum 和结构版本。
|
|
11
11
|
5. 解压并解析脱敏统计数据。
|
|
12
|
-
6.
|
|
12
|
+
6. 识别单会话、今日、近七日或本周范围;新版小票按规范事实去重,完整记录默认保存到匿名账号数据库。
|
|
13
13
|
7. 使用 Canvas 重新绘制所选模板并保存到手机相册。
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
手机本地只保留扫码分片、待入库记录和必要缓存。私人小票入库不等于自动加入公开统计;是否参与 AI 供销社匿名汇总由用户在配套小程序中单独决定。
|
|
16
|
+
|
|
17
|
+
二维码不会传输图片。桌面端和小程序端根据同一份结构数据分别渲染小票;旧版二维码没有显式范围字段时仍按原有文案兼容识别。二维码不包含 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID。
|
|
16
18
|
|
|
17
19
|
配套小程序是独立产品,本仓库不包含其源码、AppID、后台代码或服务端密钥。
|
|
18
20
|
|
package/docs/privacy.en.md
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"node": ">=20"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
+
"dom-to-image-more": "^3.10.2",
|
|
55
56
|
"qrcode": "^1.5.4"
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -7,10 +7,11 @@ 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 {
|
|
13
|
-
import {
|
|
13
|
+
import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
|
|
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";
|
|
16
17
|
import { installCodexSkill } from "./core/skill-installer.mjs";
|
|
@@ -77,18 +78,20 @@ 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
|
|
81
|
-
const
|
|
82
|
-
const
|
|
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,
|
|
91
|
-
`codex-receipt-${
|
|
94
|
+
`codex-receipt-${outputSlugForRange(range, record.id)}.html`,
|
|
92
95
|
);
|
|
93
96
|
const outputFile = path.resolve(/\.html?$/i.test(requestedOutput) ? requestedOutput : `${requestedOutput}.html`);
|
|
94
97
|
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
|
|
@@ -97,7 +100,7 @@ async function main() {
|
|
|
97
100
|
|
|
98
101
|
fs.writeFileSync(
|
|
99
102
|
outputFile,
|
|
100
|
-
renderHtml({ record,
|
|
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: ${
|
|
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(`数据二维码:${
|
|
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
|
+
}
|
package/src/core/metrics.mjs
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
25
|
+
const totals = zeroUsage();
|
|
26
|
+
const previous = zeroUsage();
|
|
37
27
|
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
40
|
+
return totals;
|
|
47
41
|
}
|
|
48
42
|
|
|
49
43
|
function calculateWorkPoints(metrics) {
|