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
|
@@ -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",
|
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
|
+
}
|
|
@@ -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
|
|