codex-work-receipt 0.3.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/src/cli.mjs ADDED
@@ -0,0 +1,109 @@
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
+ import QRCode from "qrcode";
8
+
9
+ import { parseArgs, printHelp } from "./core/args.mjs";
10
+ import { collectMetrics } from "./core/metrics.mjs";
11
+ import { encodeReceiptPayload } from "./core/qr-payload.mjs";
12
+ import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
13
+ import { installCodexSkill } from "./core/skill-installer.mjs";
14
+ import { formatNumber } from "./lib/time.mjs";
15
+ import { loadCodexSessions } from "./parsers/codex.mjs";
16
+ import { renderHtml } from "./renderers/html.mjs";
17
+
18
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
19
+ const PROJECT_DIR = path.dirname(SCRIPT_DIR);
20
+ const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), "codex-work-receipt-output");
21
+ const DEFAULT_MINIPROGRAM_CODE = path.join(PROJECT_DIR, "assets", "miniprogram-code.png");
22
+
23
+ function mimeTypeForImage(filePath) {
24
+ const extension = path.extname(filePath).toLowerCase();
25
+ if (extension === ".png") return "image/png";
26
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
27
+ if (extension === ".webp") return "image/webp";
28
+ if (extension === ".svg") return "image/svg+xml";
29
+ throw new Error(`不支持的小程序码图片格式:${extension || "未知"}`);
30
+ }
31
+
32
+ function imageAsDataUrl(filePath) {
33
+ if (!filePath || !fs.existsSync(filePath)) return null;
34
+ return `data:${mimeTypeForImage(filePath)};base64,${fs.readFileSync(filePath).toString("base64")}`;
35
+ }
36
+
37
+ function openFile(filePath) {
38
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
39
+ const args = process.platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
40
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
41
+ child.unref();
42
+ }
43
+
44
+ async function main() {
45
+ const options = parseArgs(process.argv.slice(2));
46
+ if (options.help) {
47
+ printHelp(options.locale);
48
+ return;
49
+ }
50
+ if (options.installSkill) {
51
+ const installed = installCodexSkill({ projectDir: PROJECT_DIR });
52
+ if (options.locale === "en") {
53
+ console.log(`AI Work Receipt skill installed: ${installed.targetDir}`);
54
+ console.log("You can now ask Codex: Create an AI work receipt for my latest session.");
55
+ console.log("Restart Codex if the current session does not detect the new skill.");
56
+ } else {
57
+ console.log(`AI 打工小票 Skill 已安装:${installed.targetDir}`);
58
+ console.log("以后可以直接对 Codex 说:给刚刚这次工作开一张 AI 打工小票。");
59
+ console.log("如果当前会话没有识别到新 Skill,请重启 Codex 后再试。");
60
+ }
61
+ return;
62
+ }
63
+
64
+ const sessions = loadCodexSessions(options.mode);
65
+ const metrics = collectMetrics(sessions, options.mode, options.timezone);
66
+ const record = buildReceiptRecord(metrics, options.theme, options.locale);
67
+ const qrPayload = encodeReceiptPayload(record);
68
+ const dataQrDataUrl = await QRCode.toDataURL(qrPayload, {
69
+ errorCorrectionLevel: "M",
70
+ margin: 2,
71
+ width: 360,
72
+ color: { dark: "#171713", light: "#ffffff" },
73
+ });
74
+
75
+ const requestedOutput = options.output || path.join(DEFAULT_OUTPUT_DIR, `codex-receipt-${options.mode}.html`);
76
+ const outputFile = path.resolve(/\.html?$/i.test(requestedOutput) ? requestedOutput : `${requestedOutput}.html`);
77
+ fs.mkdirSync(path.dirname(outputFile), { recursive: true });
78
+
79
+ const miniProgramCodeDataUrl = imageAsDataUrl(DEFAULT_MINIPROGRAM_CODE);
80
+
81
+ fs.writeFileSync(
82
+ outputFile,
83
+ renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl }),
84
+ "utf8",
85
+ );
86
+ const persisted = persistReceiptRecord(record, outputFile, options.dataDir);
87
+
88
+ if (options.locale === "en") {
89
+ console.log(`Generated HTML: ${outputFile}`);
90
+ console.log(`Structured data: ${persisted.companionPath}`);
91
+ console.log(`Local history: ${persisted.receiptPath}`);
92
+ console.log(`Stats: ${record.stats.completed_turns} turns · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Tokens · ${record.stats.tool_calls} tool calls`);
93
+ console.log(`Data QR: ${qrPayload.length} characters · schema v${record.schema_version}`);
94
+ if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
95
+ } else {
96
+ console.log(`已生成网页:${outputFile}`);
97
+ console.log(`结构数据:${persisted.companionPath}`);
98
+ console.log(`本地历史:${persisted.receiptPath}`);
99
+ console.log(`统计:${record.stats.completed_turns} 轮 · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Token · ${record.stats.tool_calls} 次工具调用`);
100
+ console.log(`数据二维码:${qrPayload.length} 字符 · schema v${record.schema_version}`);
101
+ if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
102
+ }
103
+ if (options.open) openFile(outputFile);
104
+ }
105
+
106
+ main().catch((error) => {
107
+ console.error(`生成失败:${error.message}`);
108
+ process.exitCode = 1;
109
+ });
@@ -0,0 +1,84 @@
1
+ export function printHelp(locale = "zh-CN") {
2
+ console.log(locale === "en" ? `
3
+ Codex AI Work Receipt
4
+
5
+ Usage:
6
+ npx codex-work-receipt@latest --latest --lang en
7
+ npx codex-work-receipt@latest --today --lang en
8
+ npx codex-work-receipt@latest --install-skill --lang en
9
+
10
+ Options:
11
+ --latest Summarize the latest active Codex session (default)
12
+ --today Summarize all Codex activity from today
13
+ --timezone <name> Use an IANA timezone, for example Asia/Shanghai
14
+ --lang <name> Receipt language: zh-CN, en
15
+ --theme <name> Default theme: classic, diner, payroll
16
+ --output <file> Set the generated HTML path
17
+ --data-dir <directory> Set the local structured-history directory
18
+ --install-skill Install the natural-language Codex skill
19
+ --no-open Do not open the browser after generation
20
+ --help Show help
21
+ ` : `
22
+ Codex AI 打工小票
23
+
24
+ 用法:
25
+ npx codex-work-receipt@latest --latest
26
+ npx codex-work-receipt@latest --today
27
+ npx codex-work-receipt@latest --install-skill
28
+
29
+ 选项:
30
+ --latest 统计最近活跃的 Codex 会话(默认)
31
+ --today 统计本地时区今天发生的全部 Codex 活动
32
+ --timezone <name> 指定 IANA 时区,例如 Asia/Shanghai
33
+ --lang <name> 小票语言:zh-CN、en
34
+ --theme <name> 默认主题:classic、diner、payroll
35
+ --output <file> 指定生成的 HTML 文件,默认写入 ./codex-work-receipt-output/
36
+ --data-dir <directory> 指定本地结构数据目录
37
+ --install-skill 安装可通过自然语言调用的 Codex Skill
38
+ --no-open 生成后不自动打开浏览器
39
+ --help 显示帮助
40
+ `);
41
+ }
42
+
43
+ export function parseArgs(argv) {
44
+ const result = {
45
+ mode: "latest",
46
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
47
+ locale: "zh-CN",
48
+ theme: "classic",
49
+ output: null,
50
+ dataDir: null,
51
+ installSkill: false,
52
+ open: true,
53
+ };
54
+
55
+ const optionsWithValues = new Map([
56
+ ["--timezone", "timezone"],
57
+ ["--lang", "locale"],
58
+ ["--theme", "theme"],
59
+ ["--output", "output"],
60
+ ["--data-dir", "dataDir"],
61
+ ]);
62
+
63
+ for (let index = 0; index < argv.length; index += 1) {
64
+ const argument = argv[index];
65
+ if (argument === "--latest") result.mode = "latest";
66
+ else if (argument === "--today") result.mode = "today";
67
+ else if (argument === "--install-skill") result.installSkill = true;
68
+ else if (argument === "--no-open") result.open = false;
69
+ else if (argument === "--help" || argument === "-h") result.help = true;
70
+ else if (optionsWithValues.has(argument)) {
71
+ const value = argv[++index];
72
+ if (!value) throw new Error(`${argument} 需要提供值`);
73
+ result[optionsWithValues.get(argument)] = value;
74
+ } else throw new Error(`不认识的参数:${argument}`);
75
+ }
76
+
77
+ if (!new Set(["classic", "diner", "payroll"]).has(result.theme)) {
78
+ throw new Error(`不支持的主题:${result.theme}`);
79
+ }
80
+ if (!new Set(["zh-CN", "en"]).has(result.locale)) {
81
+ throw new Error(`不支持的语言:${result.locale}`);
82
+ }
83
+ return result;
84
+ }
@@ -0,0 +1,149 @@
1
+ import { dateKey, rowDate } from "../lib/time.mjs";
2
+ import { selectWorkProfileId } from "./presentation.mjs";
3
+
4
+ function zeroUsage() {
5
+ return {
6
+ input_tokens: 0,
7
+ cached_input_tokens: 0,
8
+ output_tokens: 0,
9
+ reasoning_output_tokens: 0,
10
+ total_tokens: 0,
11
+ };
12
+ }
13
+
14
+ function subtractUsage(after = {}, before = {}) {
15
+ const result = zeroUsage();
16
+ for (const key of Object.keys(result)) {
17
+ result[key] = Math.max(0, Number(after[key] || 0) - Number(before[key] || 0));
18
+ }
19
+ return result;
20
+ }
21
+
22
+ function addUsage(target, source) {
23
+ for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
24
+ }
25
+
26
+ function sessionTokenUsage(rows, mode, targetDate, timezone) {
27
+ const events = rows
28
+ .filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
29
+ .filter((row) => row.payload?.info?.total_token_usage)
30
+ .sort((left, right) => (rowDate(left)?.getTime() || 0) - (rowDate(right)?.getTime() || 0));
31
+
32
+ if (!events.length) return zeroUsage();
33
+ if (mode === "latest") return { ...zeroUsage(), ...events.at(-1).payload.info.total_token_usage };
34
+
35
+ const lastToday = events.filter((row) => {
36
+ const date = rowDate(row);
37
+ return date && dateKey(date, timezone) === targetDate;
38
+ }).at(-1);
39
+ if (!lastToday) return zeroUsage();
40
+
41
+ const baseline = events.filter((row) => {
42
+ const date = rowDate(row);
43
+ return date && dateKey(date, timezone) < targetDate;
44
+ }).at(-1)?.payload.info.total_token_usage || zeroUsage();
45
+ return subtractUsage(lastToday.payload.info.total_token_usage, baseline);
46
+ }
47
+
48
+ function calculateWorkPoints(metrics) {
49
+ const workMinutes = Math.ceil(metrics.workDurationMs / 60_000);
50
+ const outputTokenK = Number(metrics.tokens.output_tokens || 0) / 1000;
51
+ const reasoningTokenK = Number(metrics.tokens.reasoning_output_tokens || 0) / 1000;
52
+ const points =
53
+ 60 +
54
+ metrics.completedTurns * 8 +
55
+ metrics.toolCalls * 3 +
56
+ outputTokenK * 1.2 +
57
+ reasoningTokenK * 1.8 +
58
+ workMinutes * 2 +
59
+ metrics.interruptions * 12;
60
+ return Math.max(0, Math.round(points));
61
+ }
62
+
63
+ export function collectMetrics(sessions, mode, timezone) {
64
+ const targetDate = dateKey(new Date(), timezone);
65
+ const scopedSessions = [];
66
+ const sessionIds = [];
67
+ const tokens = zeroUsage();
68
+ let completedTurns = 0;
69
+ let userMessages = 0;
70
+ let toolCalls = 0;
71
+ let interruptions = 0;
72
+ let workDurationMs = 0;
73
+ let totalFirstTokenMs = 0;
74
+ let firstTokenSamples = 0;
75
+ const timestamps = [];
76
+ const models = new Set();
77
+
78
+ for (const session of sessions) {
79
+ const scopedRows = mode === "latest"
80
+ ? session.rows
81
+ : session.rows.filter((row) => {
82
+ const date = rowDate(row);
83
+ return date && dateKey(date, timezone) === targetDate;
84
+ });
85
+
86
+ if (!scopedRows.length) continue;
87
+ scopedSessions.push(session);
88
+ sessionIds.push(session.sessionId);
89
+ addUsage(tokens, sessionTokenUsage(session.rows, mode, targetDate, timezone));
90
+
91
+ for (const row of scopedRows) {
92
+ const date = rowDate(row);
93
+ if (date) timestamps.push(date);
94
+
95
+ if (row.type === "turn_context" && row.payload?.model) models.add(row.payload.model);
96
+ if (row.type === "event_msg") {
97
+ const eventType = row.payload?.type;
98
+ if (eventType === "task_complete") {
99
+ completedTurns += 1;
100
+ workDurationMs += Number(row.payload.duration_ms || 0);
101
+ if (Number.isFinite(row.payload.time_to_first_token_ms)) {
102
+ totalFirstTokenMs += row.payload.time_to_first_token_ms;
103
+ firstTokenSamples += 1;
104
+ }
105
+ } else if (eventType === "user_message") userMessages += 1;
106
+ else if (eventType === "turn_aborted") {
107
+ interruptions += 1;
108
+ workDurationMs += Number(row.payload.duration_ms || 0);
109
+ }
110
+ }
111
+
112
+ if (
113
+ row.type === "response_item" &&
114
+ (row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
115
+ ) toolCalls += 1;
116
+ }
117
+
118
+ const fallbackModel = [...session.rows]
119
+ .reverse()
120
+ .find((row) => row.type === "turn_context" && row.payload?.model)?.payload?.model;
121
+ if (fallbackModel) models.add(fallbackModel);
122
+ }
123
+
124
+ if (!scopedSessions.length) {
125
+ throw new Error(mode === "today" ? `没有找到 ${targetDate} 的 Codex 活动` : "没有找到可统计的 Codex 会话");
126
+ }
127
+
128
+ timestamps.sort((left, right) => left - right);
129
+ const metrics = {
130
+ mode,
131
+ timezone,
132
+ targetDate,
133
+ sessionIds,
134
+ sessionCount: scopedSessions.length,
135
+ startAt: timestamps[0],
136
+ endAt: timestamps.at(-1),
137
+ completedTurns,
138
+ userMessages,
139
+ toolCalls,
140
+ interruptions,
141
+ workDurationMs,
142
+ averageFirstTokenMs: firstTokenSamples ? totalFirstTokenMs / firstTokenSamples : 0,
143
+ tokens,
144
+ models: [...models],
145
+ };
146
+ metrics.workProfileId = selectWorkProfileId(metrics);
147
+ metrics.workPoints = calculateWorkPoints(metrics);
148
+ return metrics;
149
+ }
@@ -0,0 +1,239 @@
1
+ export const DEFAULT_LOCALE = "zh-CN";
2
+ export const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE, "en"]);
3
+
4
+ const WORK_PROFILES = {
5
+ "change-request-survivor": {
6
+ "zh-CN": {
7
+ title: "改需求幸存者",
8
+ review: "方向改了又改,但它还是把工牌戴稳了。",
9
+ },
10
+ en: {
11
+ title: "Change Request Survivor",
12
+ review: "The direction changed again and again, but the badge stayed firmly on.",
13
+ },
14
+ },
15
+ "toolchain-commander": {
16
+ "zh-CN": {
17
+ title: "工具链指挥官",
18
+ review: "今天不是在调用工具,就是在去调用工具的路上。",
19
+ },
20
+ en: {
21
+ title: "Toolchain Commander",
22
+ review: "It was either calling tools or on its way to call another one.",
23
+ },
24
+ },
25
+ "context-devouring-beast": {
26
+ "zh-CN": {
27
+ title: "上下文吞吐兽",
28
+ review: "一口气吞下大量上下文,脑内风扇疑似起飞。",
29
+ },
30
+ en: {
31
+ title: "Context-Devouring Beast",
32
+ review: "It swallowed a massive context window in one sitting. Internal fans may have achieved liftoff.",
33
+ },
34
+ },
35
+ "continuous-delivery-machine": {
36
+ "zh-CN": {
37
+ title: "连续交付机器",
38
+ review: "一轮接一轮地干活,像被塞进了自动售货机。",
39
+ },
40
+ en: {
41
+ title: "Continuous Delivery Machine",
42
+ review: "Turn after turn, it kept shipping like a vending machine stocked with deliverables.",
43
+ },
44
+ },
45
+ "night-shift-companion": {
46
+ "zh-CN": {
47
+ title: "深夜陪跑员工",
48
+ review: "人类还没下班,它也只好继续亮着屏幕。",
49
+ },
50
+ en: {
51
+ title: "Night Shift Companion",
52
+ review: "The human was still online, so the screen had no choice but to stay lit.",
53
+ },
54
+ },
55
+ "steady-progress-partner": {
56
+ "zh-CN": {
57
+ title: "稳定推进搭子",
58
+ review: "不声不响往前推,属于靠谱但不邀功的那种。",
59
+ },
60
+ en: {
61
+ title: "Steady Progress Partner",
62
+ review: "Quietly moved the work forward—the reliable type that never asks for applause.",
63
+ },
64
+ },
65
+ "temporary-hire": {
66
+ "zh-CN": {
67
+ title: "临时上岗小工",
68
+ review: "刚打完卡,还没来得及形成职场人格。",
69
+ },
70
+ en: {
71
+ title: "Temporary Hire",
72
+ review: "It just clocked in. Its workplace personality is still loading.",
73
+ },
74
+ },
75
+ };
76
+
77
+ const RECEIPT_COPY = {
78
+ "zh-CN": {
79
+ htmlLang: "zh-CN",
80
+ pageTitle: "Codex AI 打工小票",
81
+ receiptTitle: "AI 打工小票",
82
+ themeAria: "小票主题",
83
+ receiptAria: "Codex AI 打工小票",
84
+ themes: {
85
+ classic: "经典热敏",
86
+ diner: "餐馆结账",
87
+ payroll: "办公室工资条",
88
+ },
89
+ scope: {
90
+ latest: "最近一次会话",
91
+ today: "今日全部会话",
92
+ },
93
+ modelMissing: "未记录",
94
+ meta: {
95
+ date: "日期",
96
+ hours: "营业时段",
97
+ number: "小票编号",
98
+ timezone: "时区",
99
+ },
100
+ rows: {
101
+ scope: "统计范围",
102
+ sessions: "会话数量",
103
+ turns: "完成轮次",
104
+ messages: "用户消息",
105
+ tools: "工具调用",
106
+ tokens: "消耗 Token",
107
+ cachedTokens: "其中缓存输入",
108
+ interruptions: "被人类打断",
109
+ firstResponse: "平均首次响应",
110
+ duration: "AI 工作时长",
111
+ },
112
+ units: {
113
+ sessions: ["场", "场"],
114
+ turns: ["轮", "轮"],
115
+ messages: ["条", "条"],
116
+ tools: ["次", "次"],
117
+ interruptions: ["次", "次"],
118
+ seconds: ["秒", "秒"],
119
+ },
120
+ footerThanks: "谢谢惠顾,欢迎明天继续改需求",
121
+ transferAria: "传到手机导入联",
122
+ transferTitle: "传到手机 · LOCAL TRANSFER",
123
+ transferDescription: "主小票保持完整,这一联只负责打开小程序和搬运数据",
124
+ openMiniProgram: "1 · 打开小程序",
125
+ openMiniProgramHint: "微信扫码进入 AI 打工图鉴",
126
+ importData: "2 · 扫描导入数据",
127
+ importDataHint: "在小程序里点击“从电脑导入”后扫描",
128
+ dataQrAlt: "当前小票数据二维码",
129
+ miniProgramAlt: "微信小程序码",
130
+ placeholderLabel: "小程序码",
131
+ placeholderValue: "待接入",
132
+ placeholderAria: "小程序码待接入",
133
+ transferNote: "数据码只包含时间、轮次、Token和工具调用等统计,不包含Prompt、回复正文、代码、项目路径或文件名。",
134
+ privacy: "结构数据同时保存在本机,未来可以批量导入小程序历史记录。",
135
+ },
136
+ en: {
137
+ htmlLang: "en",
138
+ pageTitle: "Codex AI Work Receipt",
139
+ receiptTitle: "AI Work Receipt",
140
+ themeAria: "Receipt theme",
141
+ receiptAria: "Codex AI Work Receipt",
142
+ themes: {
143
+ classic: "Classic Thermal",
144
+ diner: "Vintage Diner",
145
+ payroll: "Office Payroll",
146
+ },
147
+ scope: {
148
+ latest: "Latest session",
149
+ today: "All sessions today",
150
+ },
151
+ modelMissing: "Not recorded",
152
+ meta: {
153
+ date: "Date",
154
+ hours: "Work hours",
155
+ number: "Receipt No.",
156
+ timezone: "Timezone",
157
+ },
158
+ rows: {
159
+ scope: "Scope",
160
+ sessions: "Sessions",
161
+ turns: "Completed turns",
162
+ messages: "User messages",
163
+ tools: "Tool calls",
164
+ tokens: "Tokens used",
165
+ cachedTokens: "Cached input",
166
+ interruptions: "Human interruptions",
167
+ firstResponse: "Average first response",
168
+ duration: "AI work duration",
169
+ },
170
+ units: {
171
+ sessions: ["session", "sessions"],
172
+ turns: ["turn", "turns"],
173
+ messages: ["message", "messages"],
174
+ tools: ["call", "calls"],
175
+ interruptions: ["time", "times"],
176
+ seconds: ["sec", "sec"],
177
+ },
178
+ footerThanks: "Thanks for your business. More revisions welcome tomorrow.",
179
+ transferAria: "Mobile transfer stub",
180
+ transferTitle: "TO YOUR PHONE · LOCAL TRANSFER",
181
+ transferDescription: "The main receipt stays intact. This stub only opens the mini program and transfers data.",
182
+ openMiniProgram: "1 · Open mini program",
183
+ openMiniProgramHint: "Scan with WeChat to open AI Work Archive",
184
+ importData: "2 · Import receipt data",
185
+ importDataHint: "Tap “从电脑导入小票” in the mini program, then scan this code",
186
+ dataQrAlt: "Current receipt data QR code",
187
+ miniProgramAlt: "WeChat mini-program code",
188
+ placeholderLabel: "Mini program",
189
+ placeholderValue: "Pending",
190
+ placeholderAria: "Mini-program code pending",
191
+ 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.",
192
+ privacy: "Structured data is also stored locally for future batch import into your mini-program history.",
193
+ },
194
+ };
195
+
196
+ const COMPENSATION_COPY = {
197
+ "zh-CN": {
198
+ latest: "本单工资",
199
+ today: "本日工资",
200
+ unit: "AI 工分",
201
+ note: "按轮次、工具调用、Token 和改需求次数娱乐折算,不代表真实费用。",
202
+ },
203
+ en: {
204
+ latest: "SHIFT PAY",
205
+ today: "TODAY'S PAY",
206
+ unit: "AI work pts",
207
+ note: "A playful score based on turns, tool calls, Tokens, and interruptions. Not a real charge.",
208
+ },
209
+ };
210
+
211
+ export function selectWorkProfileId(metrics) {
212
+ if (metrics.interruptions >= 3) return "change-request-survivor";
213
+ if (metrics.toolCalls >= 40) return "toolchain-commander";
214
+ if (metrics.tokens.total_tokens >= 500_000) return "context-devouring-beast";
215
+ if (metrics.completedTurns >= 12) return "continuous-delivery-machine";
216
+ if (metrics.workDurationMs >= 60 * 60 * 1000) return "night-shift-companion";
217
+ if (metrics.completedTurns >= 5) return "steady-progress-partner";
218
+ return "temporary-hire";
219
+ }
220
+
221
+ export function getWorkProfileCopy(profileId, locale = DEFAULT_LOCALE) {
222
+ const profile = WORK_PROFILES[profileId] || WORK_PROFILES["temporary-hire"];
223
+ return profile[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
224
+ }
225
+
226
+ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
227
+ return RECEIPT_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
228
+ }
229
+
230
+ export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
231
+ const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
232
+ return {
233
+ label: scope === "today" ? copy.today : copy.latest,
234
+ amount: Number(amount || 0),
235
+ unit: copy.unit,
236
+ note: copy.note,
237
+ formula_version: "work_points_v1",
238
+ };
239
+ }
@@ -0,0 +1,69 @@
1
+ import crypto from "node:crypto";
2
+ import zlib from "node:zlib";
3
+
4
+ import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
5
+
6
+ export function compactReceipt(record) {
7
+ const profileId = record.presentation.work_profile;
8
+ const mobileProfile = profileId
9
+ ? getWorkProfileCopy(profileId, "zh-CN")
10
+ : { title: record.presentation.work_title, review: record.presentation.review };
11
+ const mobileCompensation = profileId
12
+ ? buildCompensation(record.source.scope, record.presentation.compensation?.amount, "zh-CN")
13
+ : record.presentation.compensation;
14
+
15
+ return {
16
+ v: record.schema_version,
17
+ i: record.id,
18
+ g: record.generated_at,
19
+ d: [record.period.start_at, record.period.end_at, record.period.timezone],
20
+ s: [
21
+ record.stats.session_count,
22
+ record.stats.completed_turns,
23
+ record.stats.user_messages,
24
+ record.stats.tool_calls,
25
+ record.stats.interruptions,
26
+ record.stats.work_duration_ms,
27
+ record.stats.average_first_token_ms,
28
+ ],
29
+ t: [
30
+ record.stats.tokens.input_tokens,
31
+ record.stats.tokens.cached_input_tokens,
32
+ record.stats.tokens.output_tokens,
33
+ record.stats.tokens.reasoning_output_tokens,
34
+ record.stats.tokens.total_tokens,
35
+ ],
36
+ m: record.stats.models,
37
+ l: record.locale || "zh-CN",
38
+ r: profileId || null,
39
+ p: [
40
+ record.presentation.default_theme,
41
+ mobileProfile.title,
42
+ mobileProfile.review,
43
+ mobileCompensation
44
+ ? [
45
+ mobileCompensation.label,
46
+ mobileCompensation.amount,
47
+ mobileCompensation.unit,
48
+ mobileCompensation.note,
49
+ mobileCompensation.formula_version,
50
+ ]
51
+ : null,
52
+ ],
53
+ };
54
+ }
55
+
56
+ export function encodeReceiptPayload(record) {
57
+ const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compactReceipt(record)), "utf8"));
58
+ const checksum = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
59
+ return `cwr1.${checksum}.${compressed.toString("base64url")}`;
60
+ }
61
+
62
+ export function decodeReceiptPayload(payload) {
63
+ const [prefix, checksum, encoded] = String(payload).split(".");
64
+ if (prefix !== "cwr1" || !checksum || !encoded) throw new Error("无效的打工小票二维码");
65
+ const compressed = Buffer.from(encoded, "base64url");
66
+ const actual = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
67
+ if (actual !== checksum) throw new Error("二维码数据校验失败");
68
+ return JSON.parse(zlib.inflateRawSync(compressed).toString("utf8"));
69
+ }