codex-work-receipt 0.5.0 → 0.6.1
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 +15 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/cli.en.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/data-schema.en.md +4 -0
- package/docs/data-schema.md +4 -0
- package/docs/mobile-import.en.md +8 -6
- package/docs/mobile-import.md +8 -6
- package/docs/privacy.en.md +2 -0
- package/docs/privacy.md +2 -0
- package/package.json +1 -1
- package/src/cli.mjs +11 -8
- package/src/core/barcode.mjs +52 -0
- 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 +12 -0
- package/src/core/qr-payload.mjs +208 -19
- 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 +223 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- 将 HTML 小票底部的重复渐变装饰替换为按小票编号生成的真实 Code 128 B 条形码,并保留适合浏览器和长图导出的静区与清晰度。
|
|
6
|
+
- 优化 multipart 扫码交互:单数据码继续与小程序码并排,多分片时先展示小程序码,再自动逐张轮播唯一可见的数据码,避免镜头同时识别多个二维码。
|
|
7
|
+
|
|
8
|
+
## 0.6.0
|
|
9
|
+
|
|
10
|
+
- 新增 cwr2 协议:按“会话 × 记账自然日”生成稳定的 canonical facts,重叠的今日、近 7 日和本周小票可由接收端可靠去重。
|
|
11
|
+
- 新增 fact、session、receipt 的稳定匿名身份,以及 manifest、content hash 和 source revision,便于幂等导入和追加式会话更新。
|
|
12
|
+
- Token 统计改为按来源顺序处理累计值重置,避免重置后出现负数或漏计。
|
|
13
|
+
- 新增 `cwr2p` 分片二维码;单个 cwr2 超出安全二维码容量时自动拆分,最多 12 片,并支持乱序重组和分片/总校验。
|
|
14
|
+
- 网页导入联可展示多张数据二维码;命令行输出二维码数量和 canonical fact 数量。
|
|
15
|
+
- 保留 cwr1 解码兼容;旧版小票仍可作为私人历史导入,但不包含精确去重所需的 canonical facts。
|
|
16
|
+
- 更新中英文手机导入、数据协议和隐私说明。
|
|
17
|
+
|
|
3
18
|
## 0.5.0
|
|
4
19
|
|
|
5
20
|
- 新增网页版“保存完整长图”功能。
|
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 that rotate one at a time in the HTML so the camera never sees multiple data codes at once.
|
|
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
|
|
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 日和本周小票即使范围重叠,接收端也能识别相同工作;数据过大时会自动生成多张可重组的分片二维码,并在 HTML 中逐张自动轮播,避免镜头同时识别多个数据码。
|
|
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
|
-
|
|
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. With one data QR, the mini-program code and data code remain side by side. When cwr2 exceeds the safe capacity of one QR code, the page shows the mini-program code first and then rotates multipart data codes one at a time without desktop clicks. The full PNG still includes every numbered part so the offline payload remains complete.
|
|
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
|
|
package/docs/data-schema.en.md
CHANGED
|
@@ -24,8 +24,12 @@ The QR payload uses compact fields:
|
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
26
|
cwr1.<checksum>.<Base64URL of deflateRaw(JSON)>
|
|
27
|
+
cwr2.<checksum>.<Base64URL of deflateRaw(JSON)>
|
|
28
|
+
cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk>
|
|
27
29
|
```
|
|
28
30
|
|
|
31
|
+
`cwr1` and `cwr2` are complete single-code payloads. `cwr2p` splits the cwr2 string into up to 12 reorderable parts. The mini program collects a transfer by transferId, validates the total checksum, reconstructs cwr2, and only then decompresses it. Multipart HTML rotates one data code at a time; the protocol does not require scan order.
|
|
32
|
+
|
|
29
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.
|
|
30
34
|
|
|
31
35
|
Compact field `o` explicitly carries `latest`, `session`, `today`, `last-7-days`, or `this-week`. `d[3]` and `d[4]` carry the calendar boundaries. These additive fields keep the `cwr1` prefix and schema version `1`, so the updated mini program can still import older QR codes.
|
package/docs/data-schema.md
CHANGED
|
@@ -24,8 +24,12 @@
|
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
26
|
cwr1.<checksum>.<deflateRaw(JSON) 的 Base64URL>
|
|
27
|
+
cwr2.<checksum>.<deflateRaw(JSON) 的 Base64URL>
|
|
28
|
+
cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk>
|
|
27
29
|
```
|
|
28
30
|
|
|
31
|
+
`cwr1` 和 `cwr2` 是完整单码。`cwr2p` 是 cwr2 单码字符串的可乱序分片,最多 12 片;小程序先按 transferId 收集全部分片,再校验总 checksum、拼回 cwr2 并解压。HTML 在多分片时每次只轮播一个数据码,协议本身不依赖扫描顺序。
|
|
32
|
+
|
|
29
33
|
小程序应检查前缀和校验值,再解压并解析数据。未来结构升级通过 `v` 字段兼容。
|
|
30
34
|
|
|
31
35
|
精简字段 `o` 显式携带 `latest`、`session`、`today`、`last-7-days` 或 `this-week`;`d[3]` 和 `d[4]` 携带自然日边界。新增字段保持 `cwr1` 和结构版本 `1`,旧二维码仍可由新版小程序导入。
|
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
|
-
3. Scan the adjacent data QR code.
|
|
10
|
-
4. Validate the `cwr1` prefix, checksum, and schema version.
|
|
11
|
-
5.
|
|
12
|
-
6. Recognize a session, today, last-seven-days, or this-week range
|
|
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
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
|
-
3.
|
|
10
|
-
4. 小程序校验 `cwr1`
|
|
11
|
-
5.
|
|
12
|
-
6.
|
|
9
|
+
3. 扫描网页旁边的数据二维码;多分片小票会在电脑上自动逐张轮播,小程序在未集齐时自动继续打开扫码。
|
|
10
|
+
4. 小程序校验 `cwr1`、`cwr2` 或 `cwr2p` 分片前缀、checksum 和结构版本。
|
|
11
|
+
5. 单码立即解压;多分片允许乱序、漏扫后等待下一轮补齐,全部到齐再解析脱敏统计数据。
|
|
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
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 {
|
|
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
|
|
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,
|
|
@@ -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,52 @@
|
|
|
1
|
+
const CODE128_PATTERNS = [
|
|
2
|
+
"212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312", "132212", "221213", "221312", "231212",
|
|
3
|
+
"112232", "122132", "122231", "113222", "123122", "123221", "223211", "221132", "221231", "213212", "223112", "312131",
|
|
4
|
+
"311222", "321122", "321221", "312212", "322112", "322211", "212123", "212321", "232121", "111323", "131123", "131321",
|
|
5
|
+
"112313", "132113", "132311", "211313", "231113", "231311", "112133", "112331", "132131", "113123", "113321", "133121",
|
|
6
|
+
"313121", "211331", "231131", "213113", "213311", "213131", "311123", "311321", "331121", "312113", "312311", "332111",
|
|
7
|
+
"314111", "221411", "431111", "111224", "111422", "121124", "121421", "141122", "141221", "112214", "112412", "122114",
|
|
8
|
+
"122411", "142112", "142211", "241211", "221114", "413111", "241112", "134111", "111242", "121142", "121241", "114212",
|
|
9
|
+
"124112", "124211", "411212", "421112", "421211", "212141", "214121", "412121", "111143", "111341", "131141", "114113",
|
|
10
|
+
"114311", "411113", "411311", "113141", "114131", "311141", "411131", "211412", "211214", "211232", "2331112",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const START_CODE_B = 104;
|
|
14
|
+
const STOP_CODE = 106;
|
|
15
|
+
|
|
16
|
+
function normalizeCode128Value(value) {
|
|
17
|
+
return [...String(value)]
|
|
18
|
+
.map((character) => {
|
|
19
|
+
const code = character.charCodeAt(0);
|
|
20
|
+
return code >= 32 && code <= 126 ? character : "?";
|
|
21
|
+
})
|
|
22
|
+
.join("")
|
|
23
|
+
.slice(0, 48) || "CWR";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildCode128B(value) {
|
|
27
|
+
const normalized = normalizeCode128Value(value);
|
|
28
|
+
const dataCodes = [...normalized].map((character) => character.charCodeAt(0) - 32);
|
|
29
|
+
const checksum = dataCodes.reduce(
|
|
30
|
+
(total, code, index) => total + code * (index + 1),
|
|
31
|
+
START_CODE_B,
|
|
32
|
+
) % 103;
|
|
33
|
+
const codes = [START_CODE_B, ...dataCodes, checksum, STOP_CODE];
|
|
34
|
+
const segments = [];
|
|
35
|
+
|
|
36
|
+
for (const code of codes) {
|
|
37
|
+
const pattern = CODE128_PATTERNS[code];
|
|
38
|
+
for (const [index, width] of [...pattern].entries()) {
|
|
39
|
+
segments.push({
|
|
40
|
+
isBar: index % 2 === 0,
|
|
41
|
+
width: Number(width),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
value: normalized,
|
|
48
|
+
checksum,
|
|
49
|
+
segments,
|
|
50
|
+
totalModules: segments.reduce((total, segment) => total + segment.width, 0),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -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) {
|