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.
@@ -133,6 +133,12 @@ const RECEIPT_COPY = {
133
133
  openMiniProgramHint: "微信扫码进入 AI 打工图鉴",
134
134
  importData: "2 · 扫描导入数据",
135
135
  importDataHint: "在小程序里点击“从电脑导入”后扫描",
136
+ multipartOpenTitle: "先打开小程序",
137
+ multipartOpenHint: "数据码将在 {seconds} 秒后自动开始轮播,无需点击电脑",
138
+ multipartTransferTitle: "保持手机对准屏幕",
139
+ multipartTransferHint: "数据码会自动逐张轮播;漏扫或重复都没关系",
140
+ multipartPartLabel: "数据分片 {current}/{total}",
141
+ multipartRestart: "重新显示小程序码",
136
142
  dataQrAlt: "当前小票数据二维码",
137
143
  miniProgramAlt: "微信小程序码",
138
144
  placeholderLabel: "小程序码",
@@ -199,6 +205,12 @@ const RECEIPT_COPY = {
199
205
  openMiniProgramHint: "Scan with WeChat to open AI Work Archive",
200
206
  importData: "2 · Import receipt data",
201
207
  importDataHint: "Tap “从电脑导入小票” in the mini program, then scan this code",
208
+ multipartOpenTitle: "Open the mini program first",
209
+ multipartOpenHint: "The data codes will start rotating automatically in {seconds}s. No desktop click is required.",
210
+ multipartTransferTitle: "Keep your phone pointed at the screen",
211
+ multipartTransferHint: "Data codes rotate automatically. Missed or duplicate scans are safe.",
212
+ multipartPartLabel: "Data part {current}/{total}",
213
+ multipartRestart: "Show mini-program code again",
202
214
  dataQrAlt: "Current receipt data QR code",
203
215
  miniProgramAlt: "WeChat mini-program code",
204
216
  placeholderLabel: "Mini program",
@@ -1,9 +1,17 @@
1
1
  import crypto from "node:crypto";
2
2
  import zlib from "node:zlib";
3
+ import QRCode from "qrcode";
3
4
 
4
5
  import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
5
6
 
6
- export function compactReceipt(record) {
7
+ const MAX_QR_VERSION = 25;
8
+ const MAX_MULTIPART_PARTS = 12;
9
+
10
+ function checksum(value, length = 8) {
11
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
12
+ }
13
+
14
+ function compactPresentation(record) {
7
15
  const profileId = record.presentation.work_profile;
8
16
  const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
9
17
  const mobileProfile = profileId
@@ -13,11 +21,21 @@ export function compactReceipt(record) {
13
21
  ? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
14
22
  : record.presentation.compensation;
15
23
 
24
+ return {
25
+ profileId,
26
+ scope,
27
+ mobileProfile,
28
+ mobileCompensation,
29
+ };
30
+ }
31
+
32
+ function compactBase(record) {
33
+ const presentation = compactPresentation(record);
16
34
  return {
17
35
  v: record.schema_version,
18
36
  i: record.id,
19
37
  g: record.generated_at,
20
- o: scope,
38
+ o: presentation.scope,
21
39
  d: [
22
40
  record.period.start_at,
23
41
  record.period.end_at,
@@ -43,35 +61,206 @@ export function compactReceipt(record) {
43
61
  ],
44
62
  m: record.stats.models,
45
63
  l: record.locale || "zh-CN",
46
- r: profileId || null,
64
+ r: presentation.profileId || null,
47
65
  p: [
48
66
  record.presentation.default_theme,
49
- mobileProfile.title,
50
- mobileProfile.review,
51
- mobileCompensation
67
+ presentation.mobileProfile.title,
68
+ presentation.mobileProfile.review,
69
+ presentation.mobileCompensation
52
70
  ? [
53
- mobileCompensation.label,
54
- mobileCompensation.amount,
55
- mobileCompensation.unit,
56
- mobileCompensation.note,
57
- mobileCompensation.formula_version,
71
+ presentation.mobileCompensation.label,
72
+ presentation.mobileCompensation.amount,
73
+ presentation.mobileCompensation.unit,
74
+ presentation.mobileCompensation.note,
75
+ presentation.mobileCompensation.formula_version,
58
76
  ]
59
77
  : null,
60
78
  ],
61
79
  };
62
80
  }
63
81
 
82
+ function compactFact(fact) {
83
+ const stats = fact.stats;
84
+ return [
85
+ fact.fact_id,
86
+ fact.session_id,
87
+ fact.identity_quality,
88
+ fact.source_type,
89
+ fact.local_date,
90
+ fact.bucket_start_at,
91
+ fact.bucket_end_at,
92
+ fact.source_watermark_at,
93
+ fact.observed_at,
94
+ [
95
+ fact.source_revision.kind,
96
+ fact.source_revision.row_count,
97
+ fact.source_revision.byte_length,
98
+ fact.source_revision.tail_hash,
99
+ ],
100
+ fact.content_hash,
101
+ [
102
+ stats.completed_turns,
103
+ stats.user_messages,
104
+ stats.tool_calls,
105
+ stats.interruptions,
106
+ stats.work_duration_ms,
107
+ stats.first_token_total_ms,
108
+ stats.first_token_sample_count,
109
+ stats.input_tokens,
110
+ stats.cached_input_tokens,
111
+ stats.output_tokens,
112
+ stats.reasoning_output_tokens,
113
+ stats.total_tokens,
114
+ stats.token_reset_count,
115
+ stats.models,
116
+ ],
117
+ ];
118
+ }
119
+
120
+ export function compactReceipt(record) {
121
+ const compact = compactBase(record);
122
+ if (record.schema_version !== 2) return compact;
123
+
124
+ const coverage = record.manifest.coverage;
125
+ return {
126
+ ...compact,
127
+ k: record.source.logical_key,
128
+ h: record.source.snapshot_hash,
129
+ a: [
130
+ record.manifest.version,
131
+ record.manifest.fact_schema_version,
132
+ record.manifest.metric_schema_version,
133
+ record.manifest.accounting_timezone,
134
+ record.manifest.fact_count,
135
+ record.manifest.fact_ids,
136
+ [
137
+ coverage.kind,
138
+ coverage.scan_mode,
139
+ coverage.start_date || null,
140
+ coverage.end_date || null,
141
+ coverage.complete_through_date || null,
142
+ coverage.observed_through_at,
143
+ ],
144
+ record.manifest.manifest_hash,
145
+ ],
146
+ f: record.facts.map(compactFact),
147
+ };
148
+ }
149
+
150
+ function encodeCompact(prefix, compact) {
151
+ const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compact), "utf8"));
152
+ return `${prefix}.${checksum(compressed)}.${compressed.toString("base64url")}`;
153
+ }
154
+
64
155
  export function encodeReceiptPayload(record) {
65
- const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compactReceipt(record)), "utf8"));
66
- const checksum = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
67
- return `cwr1.${checksum}.${compressed.toString("base64url")}`;
156
+ return encodeCompact(record.schema_version === 2 ? "cwr2" : "cwr1", compactReceipt(record));
68
157
  }
69
158
 
70
- export function decodeReceiptPayload(payload) {
71
- const [prefix, checksum, encoded] = String(payload).split(".");
72
- if (prefix !== "cwr1" || !checksum || !encoded) throw new Error("无效的打工小票二维码");
159
+ function qrVersion(payload) {
160
+ return QRCode.create(payload, { errorCorrectionLevel: "M" }).version;
161
+ }
162
+
163
+ function fitsVersion(payload, maxVersion) {
164
+ try {
165
+ return qrVersion(payload) <= maxVersion;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function multipartPayload(transferId, partIndex, partCount, totalChecksum, chunk) {
172
+ return `cwr2p.${transferId}.${partIndex}.${partCount}.${totalChecksum}.${checksum(chunk)}.${chunk}`;
173
+ }
174
+
175
+ function maxChunkSize(singlePayload, maxVersion) {
176
+ const transferId = checksum(singlePayload, 12);
177
+ const totalChecksum = checksum(singlePayload);
178
+ let low = 32;
179
+ let high = singlePayload.length;
180
+
181
+ while (low < high) {
182
+ const middle = Math.ceil((low + high) / 2);
183
+ const candidate = multipartPayload(transferId, 12, 12, totalChecksum, singlePayload.slice(0, middle));
184
+ if (fitsVersion(candidate, maxVersion)) low = middle;
185
+ else high = middle - 1;
186
+ }
187
+ return low;
188
+ }
189
+
190
+ export function encodeReceiptPayloads(record, options = {}) {
191
+ const maxVersion = options.maxVersion || MAX_QR_VERSION;
192
+ const maxParts = options.maxParts || MAX_MULTIPART_PARTS;
193
+ const singlePayload = encodeReceiptPayload(record);
194
+ if (fitsVersion(singlePayload, maxVersion)) return [singlePayload];
195
+ if (!singlePayload.startsWith("cwr2.")) {
196
+ throw new Error("旧版小票数据超过单个二维码容量");
197
+ }
198
+
199
+ const chunkSize = maxChunkSize(singlePayload, maxVersion);
200
+ const chunks = [];
201
+ for (let offset = 0; offset < singlePayload.length; offset += chunkSize) {
202
+ chunks.push(singlePayload.slice(offset, offset + chunkSize));
203
+ }
204
+ if (chunks.length > maxParts) {
205
+ throw new Error(`小票数据需要 ${chunks.length} 个二维码,超过 ${maxParts} 个上限,请缩小统计范围`);
206
+ }
207
+
208
+ const transferId = checksum(singlePayload, 12);
209
+ const totalChecksum = checksum(singlePayload);
210
+ return chunks.map((chunk, index) => (
211
+ multipartPayload(transferId, index + 1, chunks.length, totalChecksum, chunk)
212
+ ));
213
+ }
214
+
215
+ function decodeSinglePayload(payload) {
216
+ const [prefix, expectedChecksum, encoded] = String(payload).split(".");
217
+ if ((prefix !== "cwr1" && prefix !== "cwr2") || !expectedChecksum || !encoded) {
218
+ throw new Error("无效的打工小票二维码");
219
+ }
73
220
  const compressed = Buffer.from(encoded, "base64url");
74
- const actual = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
75
- if (actual !== checksum) throw new Error("二维码数据校验失败");
221
+ if (checksum(compressed) !== expectedChecksum) throw new Error("二维码数据校验失败");
76
222
  return JSON.parse(zlib.inflateRawSync(compressed).toString("utf8"));
77
223
  }
224
+
225
+ export function decodeReceiptPayload(payload) {
226
+ return decodeSinglePayload(payload);
227
+ }
228
+
229
+ export function decodeMultipartReceiptPayloads(payloads) {
230
+ const parts = payloads.map((payload) => {
231
+ const fields = String(payload).split(".");
232
+ const [prefix, transferId, indexText, countText, totalChecksum, partChecksum] = fields;
233
+ const chunk = fields.slice(6).join(".");
234
+ const index = Number(indexText);
235
+ const count = Number(countText);
236
+ if (
237
+ prefix !== "cwr2p" || !transferId || !totalChecksum || !chunk ||
238
+ !Number.isInteger(index) || !Number.isInteger(count) || index < 1 || index > count || count > MAX_MULTIPART_PARTS ||
239
+ checksum(chunk) !== partChecksum
240
+ ) throw new Error("无效的分片二维码");
241
+ return { transferId, index, count, totalChecksum, chunk };
242
+ });
243
+ if (!parts.length) throw new Error("缺少分片二维码");
244
+ const first = parts[0];
245
+ if (parts.some((part) => (
246
+ part.transferId !== first.transferId || part.count !== first.count || part.totalChecksum !== first.totalChecksum
247
+ ))) throw new Error("分片二维码不属于同一张小票");
248
+ const unique = new Map(parts.map((part) => [part.index, part]));
249
+ if (unique.size !== first.count) throw new Error("分片二维码尚未集齐");
250
+ const singlePayload = [...unique.values()].sort((left, right) => left.index - right.index).map((part) => part.chunk).join("");
251
+ if (checksum(singlePayload) !== first.totalChecksum) throw new Error("分片二维码总校验失败");
252
+ return decodeSinglePayload(singlePayload);
253
+ }
254
+
255
+ export function inspectReceiptPayloadPart(payload) {
256
+ const fields = String(payload).split(".");
257
+ const [prefix, transferId, indexText, countText, totalChecksum, partChecksum] = fields;
258
+ const chunk = fields.slice(6).join(".");
259
+ if (prefix !== "cwr2p") return null;
260
+ const index = Number(indexText);
261
+ const count = Number(countText);
262
+ if (!transferId || !chunk || checksum(chunk) !== partChecksum || !Number.isInteger(index) || !Number.isInteger(count)) {
263
+ throw new Error("无效的分片二维码");
264
+ }
265
+ return { transferId, index, count, totalChecksum };
266
+ }
@@ -3,28 +3,27 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ import { canonicalStringify, sha256Hex } from "./canonical.mjs";
7
+ import { buildLogicalReceiptKey, buildProtocolReceiptId } from "./fact-identity.mjs";
6
8
  import {
7
9
  buildCompensation,
8
10
  DEFAULT_LOCALE,
9
11
  getWorkProfileCopy,
10
12
  } from "./presentation.mjs";
11
13
 
12
- const SCHEMA_VERSION = 1;
13
- const COLLECTOR_VERSION = "0.5.0";
14
+ const SCHEMA_VERSION = 2;
15
+ const SOURCE_VERSION = "cwr2";
16
+ const COLLECTOR_VERSION = "0.6.0";
14
17
 
15
18
  function fingerprintSessionIds(sessionIds) {
16
19
  return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
17
20
  }
18
21
 
19
- export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE) {
22
+ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
20
23
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
21
- const logicalKey = metrics.mode === "latest" || metrics.mode === "session"
22
- ? `latest:${sessionFingerprint}`
23
- : metrics.mode === "this-week"
24
- ? `this-week:${metrics.rangeStartDate}:${metrics.timezone}`
25
- : `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
26
- const id = `cwr_${crypto.createHash("sha256").update(logicalKey).digest("hex").slice(0, 16)}`;
27
- const snapshotHash = crypto.createHash("sha256").update(JSON.stringify({
24
+ const logicalKey = buildLogicalReceiptKey(metrics);
25
+ const id = buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
26
+ const snapshotHash = sha256Hex({
28
27
  start: metrics.startAt.toISOString(),
29
28
  end: metrics.endAt.toISOString(),
30
29
  tokens: metrics.tokens,
@@ -34,9 +33,31 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
34
33
  scope: metrics.mode,
35
34
  rangeStartDate: metrics.rangeStartDate,
36
35
  rangeEndDate: metrics.rangeEndDate,
37
- })).digest("hex").slice(0, 16);
36
+ });
38
37
  const workProfileId = metrics.workProfileId || "temporary-hire";
39
38
  const workProfile = getWorkProfileCopy(workProfileId, locale);
39
+ const facts = Array.isArray(canonical.facts) ? canonical.facts : [];
40
+ const coverage = canonical.coverage || {
41
+ kind: "selected_sessions",
42
+ scan_mode: "none",
43
+ start_date: metrics.rangeStartDate,
44
+ end_date: metrics.rangeEndDate,
45
+ complete_through_date: null,
46
+ observed_through_at: new Date().toISOString(),
47
+ };
48
+ const manifestCore = {
49
+ version: 1,
50
+ fact_schema_version: 1,
51
+ metric_schema_version: 1,
52
+ accounting_timezone: "Asia/Shanghai",
53
+ fact_count: facts.length,
54
+ fact_ids: facts.map((fact) => fact.fact_id),
55
+ coverage,
56
+ };
57
+ const manifest = {
58
+ ...manifestCore,
59
+ manifest_hash: sha256Hex(canonicalStringify(manifestCore)),
60
+ };
40
61
 
41
62
  return {
42
63
  schema_version: SCHEMA_VERSION,
@@ -45,6 +66,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
45
66
  generated_at: new Date().toISOString(),
46
67
  source: {
47
68
  type: "codex",
69
+ version: SOURCE_VERSION,
48
70
  scope: metrics.mode,
49
71
  collector_version: COLLECTOR_VERSION,
50
72
  logical_key: logicalKey,
@@ -68,6 +90,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
68
90
  average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
69
91
  tokens: { ...metrics.tokens },
70
92
  models: [...metrics.models],
93
+ receipt_work_points: metrics.workPoints,
94
+ receipt_formula_version: "receipt_work_points_v1",
71
95
  },
72
96
  presentation: {
73
97
  default_theme: defaultTheme,
@@ -83,6 +107,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
83
107
  contains_paths: false,
84
108
  contains_filenames: false,
85
109
  },
110
+ manifest,
111
+ facts,
86
112
  };
87
113
  }
88
114
 
@@ -0,0 +1,17 @@
1
+ export function compareSourceRevision(candidate, current) {
2
+ if (!current) return "newer";
3
+ if (!candidate) return "conflict";
4
+
5
+ const candidateRows = Number(candidate.row_count ?? candidate.rowCount ?? 0);
6
+ const currentRows = Number(current.row_count ?? current.rowCount ?? 0);
7
+ const candidateBytes = Number(candidate.byte_length ?? candidate.byteLength ?? 0);
8
+ const currentBytes = Number(current.byte_length ?? current.byteLength ?? 0);
9
+ const candidateTail = String(candidate.tail_hash ?? candidate.tailHash ?? "");
10
+ const currentTail = String(current.tail_hash ?? current.tailHash ?? "");
11
+
12
+ if (candidateRows === currentRows && candidateBytes === currentBytes) {
13
+ return candidateTail === currentTail ? "same" : "conflict";
14
+ }
15
+ if (candidateRows >= currentRows && candidateBytes >= currentBytes) return "newer";
16
+ return "stale";
17
+ }
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -20,24 +21,33 @@ function readJsonl(filePath) {
20
21
  for (const [lineIndex, line] of source.split("\n").entries()) {
21
22
  if (!line.trim()) continue;
22
23
  try {
23
- rows.push(JSON.parse(line));
24
+ rows.push({ ...JSON.parse(line), __sourceLine: lineIndex + 1 });
24
25
  } catch {
25
26
  console.warn(`跳过无法解析的记录:${path.basename(filePath)}:${lineIndex + 1}`);
26
27
  }
27
28
  }
28
- return rows;
29
+ return { rows, source };
29
30
  }
30
31
 
31
32
  function sessionFromFile(file) {
32
- const rows = readJsonl(file.filePath);
33
+ const { rows, source } = readJsonl(file.filePath);
33
34
  const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
35
+ const metadataSessionId = meta.session_id || meta.id || null;
34
36
  const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
35
37
  const fallbackDate = new Date(file.modifiedAt);
38
+ const sourceBuffer = Buffer.from(source, "utf8");
36
39
  return {
37
40
  rows,
38
41
  filePath: file.filePath,
39
42
  modifiedAt: file.modifiedAt,
40
- sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
43
+ sessionId: metadataSessionId || path.basename(file.filePath, ".jsonl"),
44
+ identityQuality: metadataSessionId ? "metadata" : "filename_fallback",
45
+ sourceRevision: {
46
+ kind: "append-only-jsonl-v1",
47
+ row_count: rows.length,
48
+ byte_length: sourceBuffer.byteLength,
49
+ tail_hash: crypto.createHash("sha256").update(sourceBuffer.subarray(-4096)).digest("hex"),
50
+ },
41
51
  startAt: timestamps[0] || fallbackDate,
42
52
  endAt: timestamps.at(-1) || fallbackDate,
43
53
  };
@@ -62,6 +72,7 @@ function calendarCandidates(files, startDate) {
62
72
  export function loadCodexSessions(range) {
63
73
  const files = codexSessionFiles();
64
74
  let candidates = files;
75
+ let scanMode = "none";
65
76
 
66
77
  if (range.scope === "latest") {
67
78
  candidates = files.slice(0, 40);
@@ -69,19 +80,28 @@ export function loadCodexSessions(range) {
69
80
  const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
70
81
  candidates = filenameMatches.length ? filenameMatches : files;
71
82
  } else {
72
- candidates = calendarCandidates(files, range.startDate);
83
+ const fullScan = process.env.CODEX_WORK_RECEIPT_FULL_SCAN === "1";
84
+ candidates = fullScan ? files : calendarCandidates(files, range.startDate);
85
+ scanMode = fullScan ? "full" : "best_effort";
73
86
  }
74
87
 
75
88
  const sessions = candidates
76
89
  .map(sessionFromFile)
77
90
  .sort((left, right) => right.endAt - left.endAt);
78
91
 
79
- if (range.scope === "latest") return sessions.slice(0, 1);
92
+ if (range.scope === "latest") {
93
+ const selected = sessions.slice(0, 1);
94
+ selected.scanMode = scanMode;
95
+ return selected;
96
+ }
80
97
  if (range.scope === "session") {
81
98
  const selected = sessions.find((session) => session.sessionId === range.sessionId);
82
99
  if (!selected) throw new Error(`没有找到指定的 Codex 会话:${range.sessionId}`);
83
- return [selected];
100
+ const result = [selected];
101
+ result.scanMode = scanMode;
102
+ return result;
84
103
  }
104
+ sessions.scanMode = scanMode;
85
105
  return sessions;
86
106
  }
87
107