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
|
@@ -80,11 +80,15 @@ const RECEIPT_COPY = {
|
|
|
80
80
|
pageTitle: "Codex AI 打工小票",
|
|
81
81
|
receiptTitle: "AI 打工小票",
|
|
82
82
|
themeAria: "小票主题",
|
|
83
|
+
exportImage: "保存完整长图",
|
|
84
|
+
exportingImage: "正在生成…",
|
|
85
|
+
exportSuccess: "图片已保存",
|
|
86
|
+
exportError: "图片生成失败,请重试",
|
|
83
87
|
receiptAria: "Codex AI 打工小票",
|
|
84
88
|
themes: {
|
|
85
|
-
classic: "
|
|
86
|
-
diner: "
|
|
87
|
-
payroll: "
|
|
89
|
+
classic: "经典白票",
|
|
90
|
+
diner: "复古粉票",
|
|
91
|
+
payroll: "夜班绿票",
|
|
88
92
|
},
|
|
89
93
|
scope: {
|
|
90
94
|
latest: "最近一次会话",
|
|
@@ -142,11 +146,15 @@ const RECEIPT_COPY = {
|
|
|
142
146
|
pageTitle: "Codex AI Work Receipt",
|
|
143
147
|
receiptTitle: "AI Work Receipt",
|
|
144
148
|
themeAria: "Receipt theme",
|
|
149
|
+
exportImage: "Save full PNG",
|
|
150
|
+
exportingImage: "Rendering…",
|
|
151
|
+
exportSuccess: "Image saved",
|
|
152
|
+
exportError: "Could not generate the image. Please try again.",
|
|
145
153
|
receiptAria: "Codex AI Work Receipt",
|
|
146
154
|
themes: {
|
|
147
|
-
classic: "Classic
|
|
148
|
-
diner: "Vintage
|
|
149
|
-
payroll: "
|
|
155
|
+
classic: "Classic White",
|
|
156
|
+
diner: "Vintage Pink",
|
|
157
|
+
payroll: "Night Shift Green",
|
|
150
158
|
},
|
|
151
159
|
scope: {
|
|
152
160
|
latest: "Latest session",
|
package/src/core/qr-payload.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/range.mjs
CHANGED
|
@@ -91,9 +91,28 @@ export function calendarDayCount(range) {
|
|
|
91
91
|
return count;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
function safeSlugSegment(value, fallback = "receipt") {
|
|
95
|
+
const normalized = String(value || "")
|
|
96
|
+
.trim()
|
|
97
|
+
.replace(/^cwr_/, "")
|
|
98
|
+
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
|
99
|
+
.replace(/^-+|-+$/g, "")
|
|
100
|
+
.slice(0, 24);
|
|
101
|
+
return normalized || fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function outputSlugForRange(range, receiptId = "") {
|
|
105
|
+
const scope = normalizeScope(range?.scope) || "latest";
|
|
106
|
+
const startDate = range?.startDate || range?.targetDate || "";
|
|
107
|
+
const endDate = range?.endDate || range?.targetDate || startDate;
|
|
108
|
+
|
|
109
|
+
if (scope === "today") return `today-${safeSlugSegment(endDate, "today")}`;
|
|
110
|
+
if (scope === "last-7-days" || scope === "this-week") {
|
|
111
|
+
const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
|
|
112
|
+
return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
|
|
113
|
+
}
|
|
114
|
+
if (scope === "session") {
|
|
115
|
+
return `session-${safeSlugSegment(range?.sessionId || receiptId, "selected")}`;
|
|
116
|
+
}
|
|
117
|
+
return `latest-${safeSlugSegment(receiptId || endDate, "receipt")}`;
|
|
99
118
|
}
|
|
@@ -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 =
|
|
13
|
-
const
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
})
|
|
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
|
+
}
|
package/src/parsers/codex.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
|