codex-work-receipt 0.7.2 → 0.7.3
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 +9 -0
- package/assets/codex-pet/ai-work-receipt/spritesheet.webp +0 -0
- package/docs/privacy.en.md +2 -0
- package/docs/privacy.md +2 -0
- package/package.json +4 -4
- package/src/cli.mjs +3 -0
- package/src/core/open-source.mjs +43 -0
- package/src/core/receipt-record.mjs +7 -5
- package/src/parsers/codex.mjs +188 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.3
|
|
4
|
+
|
|
5
|
+
- Codex 会话日志改为分块逐行解析,避免超大 `.jsonl` 在转成单个 JavaScript 字符串时触发 V8 长度上限。
|
|
6
|
+
- 会话行解析后立即丢弃 Prompt、回复、图片和工具输出,只在内存中保留统计所需的时间、事件、模型与 Token 字段。
|
|
7
|
+
- 单行异常过大时带文件路径和行号跳过;文件读取失败时补充路径与体积,便于定位具体日志。
|
|
8
|
+
- 本地 `history.jsonl` 改为逐条写入,避免历史增长后形成同类的大字符串拼接风险。
|
|
9
|
+
- 小票生成或票仔安装成功后在终端展示开源仓库地址,并用对应的中英文文案邀请用户为项目点 Star。
|
|
10
|
+
- 修正 npm 包的 repository、homepage 和 issues 地址,使其统一指向 `a-bai-2026/codex-work-receipt`。
|
|
11
|
+
|
|
3
12
|
## 0.7.2
|
|
4
13
|
|
|
5
14
|
- 修复同一 Codex 会话存在多份 append-only 日志修订时生成重复 canonical factId、导致小程序云端入库拒绝的问题。
|
|
Binary file
|
package/docs/privacy.en.md
CHANGED
|
@@ -10,6 +10,8 @@ AI Work Receipt reads Codex sessions, computes metrics, and renders receipts loc
|
|
|
10
10
|
- Event types, timestamps, model names, and numerical metadata
|
|
11
11
|
- Metrics such as turns, tool calls, Tokens, duration, and interruptions
|
|
12
12
|
|
|
13
|
+
Session logs are parsed locally in bounded chunks, one JSONL row at a time. After each row is parsed, only fields needed for metrics remain in memory; prompts, responses, images, and tool output are discarded from the in-memory statistics representation.
|
|
14
|
+
|
|
13
15
|
## What receipts exclude
|
|
14
16
|
|
|
15
17
|
- Prompts and response text
|
package/docs/privacy.md
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-work-receipt",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Generate a privacy-first Codex work receipt from local session metadata.",
|
|
6
6
|
"author": "a-bai-2026",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/a-bai-2026/
|
|
9
|
+
"url": "git+https://github.com/a-bai-2026/codex-work-receipt.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/a-bai-2026/
|
|
11
|
+
"homepage": "https://github.com/a-bai-2026/codex-work-receipt#readme",
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/a-bai-2026/
|
|
13
|
+
"url": "https://github.com/a-bai-2026/codex-work-receipt/issues"
|
|
14
14
|
},
|
|
15
15
|
"license": "GPL-3.0-only",
|
|
16
16
|
"keywords": [
|
package/src/cli.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import QRCode from "qrcode";
|
|
|
9
9
|
import { parseArgs, printHelp } from "./core/args.mjs";
|
|
10
10
|
import { buildCanonicalFacts } from "./core/fact-buckets.mjs";
|
|
11
11
|
import { collectMetrics } from "./core/metrics.mjs";
|
|
12
|
+
import { printOpenSourcePrompt } from "./core/open-source.mjs";
|
|
12
13
|
import { getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
|
|
13
14
|
import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
|
|
14
15
|
import { outputSlugForRange, resolveRange } from "./core/range.mjs";
|
|
@@ -81,6 +82,7 @@ async function main() {
|
|
|
81
82
|
console.log("请重启 Codex,打开 Settings > Pets,点击 Refresh 后选择“票仔 · AI 小票工”。");
|
|
82
83
|
console.log("输入 /pet 唤醒票仔;以后可以说:票仔,开今天的票。");
|
|
83
84
|
}
|
|
85
|
+
printOpenSourcePrompt("pet", options.locale);
|
|
84
86
|
return;
|
|
85
87
|
}
|
|
86
88
|
if (options.uninstallPet) {
|
|
@@ -163,6 +165,7 @@ async function main() {
|
|
|
163
165
|
}
|
|
164
166
|
if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
|
|
165
167
|
}
|
|
168
|
+
printOpenSourcePrompt("receipt", options.locale);
|
|
166
169
|
if (options.open) openFile(outputFile);
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const OPEN_SOURCE_REPOSITORY_URL = "https://github.com/a-bai-2026/codex-work-receipt";
|
|
2
|
+
|
|
3
|
+
const PROMPTS = {
|
|
4
|
+
"zh-CN": {
|
|
5
|
+
receipt: {
|
|
6
|
+
label: "开源项目",
|
|
7
|
+
message: "如果你也喜欢这个 AI 小票工具,欢迎来 GitHub 给我点个 Star ⭐",
|
|
8
|
+
},
|
|
9
|
+
pet: {
|
|
10
|
+
label: "票仔开源地址",
|
|
11
|
+
message: "如果你也喜欢票仔,欢迎来 GitHub 给我点个 Star ⭐",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
en: {
|
|
15
|
+
receipt: {
|
|
16
|
+
label: "Open-source project",
|
|
17
|
+
message: "If you enjoy AI Work Receipt, a GitHub Star would mean a lot ⭐",
|
|
18
|
+
},
|
|
19
|
+
pet: {
|
|
20
|
+
label: "Ticket Buddy source",
|
|
21
|
+
message: "If you enjoy Ticket Buddy, a GitHub Star would mean a lot ⭐",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function getOpenSourcePrompt(kind = "receipt", locale = "zh-CN") {
|
|
27
|
+
const normalizedLocale = locale === "en" ? "en" : "zh-CN";
|
|
28
|
+
const localized = PROMPTS[normalizedLocale];
|
|
29
|
+
const copy = localized[kind] || localized.receipt;
|
|
30
|
+
return {
|
|
31
|
+
url: OPEN_SOURCE_REPOSITORY_URL,
|
|
32
|
+
label: copy.label,
|
|
33
|
+
message: copy.message,
|
|
34
|
+
separator: normalizedLocale === "en" ? ": " : ":",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function printOpenSourcePrompt(kind, locale, writeLine = console.log) {
|
|
39
|
+
const prompt = getOpenSourcePrompt(kind, locale);
|
|
40
|
+
writeLine("");
|
|
41
|
+
writeLine(`${prompt.label}${prompt.separator}${prompt.url}`);
|
|
42
|
+
writeLine(prompt.message);
|
|
43
|
+
}
|
|
@@ -155,11 +155,13 @@ export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir =
|
|
|
155
155
|
}
|
|
156
156
|
const history = [...deduplicated.values()]
|
|
157
157
|
.sort((left, right) => String(left.period?.end_at).localeCompare(String(right.period?.end_at)));
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"utf8"
|
|
162
|
-
|
|
158
|
+
const historyPath = path.join(dataDir, "history.jsonl");
|
|
159
|
+
const historyDescriptor = fs.openSync(historyPath, "w");
|
|
160
|
+
try {
|
|
161
|
+
for (const item of history) fs.writeSync(historyDescriptor, `${JSON.stringify(item)}\n`, null, "utf8");
|
|
162
|
+
} finally {
|
|
163
|
+
fs.closeSync(historyDescriptor);
|
|
164
|
+
}
|
|
163
165
|
|
|
164
166
|
const companionPath = /\.html?$/i.test(outputHtmlPath)
|
|
165
167
|
? outputHtmlPath.replace(/\.html?$/i, ".json")
|
package/src/parsers/codex.mjs
CHANGED
|
@@ -5,6 +5,17 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { rowDate } from "../lib/time.mjs";
|
|
7
7
|
|
|
8
|
+
const READ_CHUNK_BYTES = 256 * 1024;
|
|
9
|
+
const MAX_JSONL_ROW_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const TAIL_HASH_BYTES = 4096;
|
|
11
|
+
const TOKEN_KEYS = [
|
|
12
|
+
"input_tokens",
|
|
13
|
+
"cached_input_tokens",
|
|
14
|
+
"output_tokens",
|
|
15
|
+
"reasoning_output_tokens",
|
|
16
|
+
"total_tokens",
|
|
17
|
+
];
|
|
18
|
+
|
|
8
19
|
function walkJsonlFiles(directory, accumulator = []) {
|
|
9
20
|
if (!fs.existsSync(directory)) return accumulator;
|
|
10
21
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
@@ -15,27 +26,183 @@ function walkJsonlFiles(directory, accumulator = []) {
|
|
|
15
26
|
return accumulator;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
function
|
|
29
|
+
function shortString(value, maximumLength = 512) {
|
|
30
|
+
return typeof value === "string" && value.length <= maximumLength ? value : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function finiteNumber(value) {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compactTokenUsage(value) {
|
|
38
|
+
if (!value || typeof value !== "object") return null;
|
|
39
|
+
const usage = {};
|
|
40
|
+
for (const key of TOKEN_KEYS) {
|
|
41
|
+
const amount = finiteNumber(value[key]);
|
|
42
|
+
if (amount !== null) usage[key] = amount;
|
|
43
|
+
}
|
|
44
|
+
return Object.keys(usage).length ? usage : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compactPayload(rowType, value) {
|
|
48
|
+
if (!value || typeof value !== "object") return null;
|
|
49
|
+
const payload = {};
|
|
50
|
+
for (const key of ["completed_at", "started_at", "timestamp"]) {
|
|
51
|
+
const timestamp = shortString(value[key], 128);
|
|
52
|
+
if (timestamp !== null) payload[key] = timestamp;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const payloadType = shortString(value.type, 128);
|
|
56
|
+
if (payloadType !== null) payload.type = payloadType;
|
|
57
|
+
|
|
58
|
+
if (rowType === "session_meta") {
|
|
59
|
+
const sessionId = shortString(value.session_id);
|
|
60
|
+
const fallbackId = shortString(value.id);
|
|
61
|
+
if (sessionId !== null) payload.session_id = sessionId;
|
|
62
|
+
if (fallbackId !== null) payload.id = fallbackId;
|
|
63
|
+
} else if (rowType === "turn_context") {
|
|
64
|
+
const model = shortString(value.model);
|
|
65
|
+
if (model !== null) payload.model = model;
|
|
66
|
+
} else if (rowType === "event_msg") {
|
|
67
|
+
const durationMs = finiteNumber(value.duration_ms);
|
|
68
|
+
const firstTokenMs = finiteNumber(value.time_to_first_token_ms);
|
|
69
|
+
if (durationMs !== null) payload.duration_ms = durationMs;
|
|
70
|
+
if (firstTokenMs !== null) payload.time_to_first_token_ms = firstTokenMs;
|
|
71
|
+
const totalTokenUsage = compactTokenUsage(value.info?.total_token_usage);
|
|
72
|
+
if (totalTokenUsage) payload.info = { total_token_usage: totalTokenUsage };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Object.keys(payload).length ? payload : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function compactRow(value, sourceLine) {
|
|
79
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
80
|
+
const row = { __sourceLine: sourceLine };
|
|
81
|
+
const timestamp = shortString(value.timestamp, 128);
|
|
82
|
+
const type = shortString(value.type, 128);
|
|
83
|
+
if (timestamp !== null) row.timestamp = timestamp;
|
|
84
|
+
if (type !== null) row.type = type;
|
|
85
|
+
const payload = compactPayload(type, value.payload);
|
|
86
|
+
if (payload) row.payload = payload;
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatByteSize(value) {
|
|
91
|
+
const bytes = Math.max(0, Number(value || 0));
|
|
92
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
93
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
94
|
+
return `${bytes} B`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function appendTail(current, chunk) {
|
|
98
|
+
if (chunk.length >= TAIL_HASH_BYTES) return Buffer.from(chunk.subarray(chunk.length - TAIL_HASH_BYTES));
|
|
99
|
+
const combined = Buffer.concat([current, chunk]);
|
|
100
|
+
return combined.length > TAIL_HASH_BYTES
|
|
101
|
+
? Buffer.from(combined.subarray(combined.length - TAIL_HASH_BYTES))
|
|
102
|
+
: combined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sessionReadError(file, error) {
|
|
106
|
+
return new Error(
|
|
107
|
+
`无法读取 Codex 会话:${file.filePath}(${formatByteSize(file.size)}):${error.message}`,
|
|
108
|
+
{ cause: error },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readJsonl(file) {
|
|
19
113
|
const rows = [];
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
114
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
115
|
+
const lineParts = [];
|
|
116
|
+
let lineBytes = 0;
|
|
117
|
+
let lineIndex = 0;
|
|
118
|
+
let offset = 0;
|
|
119
|
+
let tail = Buffer.alloc(0);
|
|
120
|
+
let skippingOversizedLine = false;
|
|
121
|
+
let fileDescriptor = null;
|
|
122
|
+
|
|
123
|
+
const resetLine = () => {
|
|
124
|
+
lineParts.length = 0;
|
|
125
|
+
lineBytes = 0;
|
|
126
|
+
skippingOversizedLine = false;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const appendLinePart = (part) => {
|
|
130
|
+
lineBytes += part.length;
|
|
131
|
+
if (skippingOversizedLine) return;
|
|
132
|
+
if (lineBytes > MAX_JSONL_ROW_BYTES) {
|
|
133
|
+
skippingOversizedLine = true;
|
|
134
|
+
lineParts.length = 0;
|
|
135
|
+
return;
|
|
27
136
|
}
|
|
137
|
+
if (part.length) lineParts.push(Buffer.from(part));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const finishLine = (finalPart = Buffer.alloc(0)) => {
|
|
141
|
+
lineIndex += 1;
|
|
142
|
+
const totalBytes = lineBytes + finalPart.length;
|
|
143
|
+
if (skippingOversizedLine || totalBytes > MAX_JSONL_ROW_BYTES) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`跳过过大的 Codex 记录:${file.filePath}:${lineIndex}(${formatByteSize(totalBytes)})`,
|
|
146
|
+
);
|
|
147
|
+
resetLine();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lineBuffer = lineParts.length
|
|
152
|
+
? Buffer.concat([...lineParts, finalPart], totalBytes)
|
|
153
|
+
: finalPart;
|
|
154
|
+
const line = lineBuffer.toString("utf8").replace(/\r$/, "");
|
|
155
|
+
if (line.trim()) {
|
|
156
|
+
try {
|
|
157
|
+
const compact = compactRow(JSON.parse(line), lineIndex);
|
|
158
|
+
if (compact) rows.push(compact);
|
|
159
|
+
} catch {
|
|
160
|
+
console.warn(`跳过无法解析的记录:${file.filePath}:${lineIndex}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
resetLine();
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
fileDescriptor = fs.openSync(file.filePath, "r");
|
|
168
|
+
const snapshotBytes = Math.max(0, Number(file.size || 0));
|
|
169
|
+
while (offset < snapshotBytes) {
|
|
170
|
+
const requestedBytes = Math.min(buffer.length, snapshotBytes - offset);
|
|
171
|
+
const bytesRead = fs.readSync(fileDescriptor, buffer, 0, requestedBytes, offset);
|
|
172
|
+
if (!bytesRead) break;
|
|
173
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
174
|
+
tail = appendTail(tail, chunk);
|
|
175
|
+
offset += bytesRead;
|
|
176
|
+
|
|
177
|
+
let start = 0;
|
|
178
|
+
let newlineIndex = chunk.indexOf(10, start);
|
|
179
|
+
while (newlineIndex !== -1) {
|
|
180
|
+
finishLine(chunk.subarray(start, newlineIndex));
|
|
181
|
+
start = newlineIndex + 1;
|
|
182
|
+
newlineIndex = chunk.indexOf(10, start);
|
|
183
|
+
}
|
|
184
|
+
appendLinePart(chunk.subarray(start));
|
|
185
|
+
}
|
|
186
|
+
if (lineBytes || skippingOversizedLine) finishLine();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw sessionReadError(file, error);
|
|
189
|
+
} finally {
|
|
190
|
+
if (fileDescriptor !== null) fs.closeSync(fileDescriptor);
|
|
28
191
|
}
|
|
29
|
-
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
rows,
|
|
195
|
+
byteLength: offset,
|
|
196
|
+
tailHash: crypto.createHash("sha256").update(tail).digest("hex"),
|
|
197
|
+
};
|
|
30
198
|
}
|
|
31
199
|
|
|
32
200
|
function sessionFromFile(file) {
|
|
33
|
-
const { rows,
|
|
201
|
+
const { rows, byteLength, tailHash } = readJsonl(file);
|
|
34
202
|
const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
|
|
35
203
|
const metadataSessionId = meta.session_id || meta.id || null;
|
|
36
204
|
const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
|
|
37
205
|
const fallbackDate = new Date(file.modifiedAt);
|
|
38
|
-
const sourceBuffer = Buffer.from(source, "utf8");
|
|
39
206
|
return {
|
|
40
207
|
rows,
|
|
41
208
|
filePath: file.filePath,
|
|
@@ -45,8 +212,8 @@ function sessionFromFile(file) {
|
|
|
45
212
|
sourceRevision: {
|
|
46
213
|
kind: "append-only-jsonl-v1",
|
|
47
214
|
row_count: rows.length,
|
|
48
|
-
byte_length:
|
|
49
|
-
tail_hash:
|
|
215
|
+
byte_length: byteLength,
|
|
216
|
+
tail_hash: tailHash,
|
|
50
217
|
},
|
|
51
218
|
startAt: timestamps[0] || fallbackDate,
|
|
52
219
|
endAt: timestamps.at(-1) || fallbackDate,
|
|
@@ -57,7 +224,14 @@ function codexSessionFiles() {
|
|
|
57
224
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
58
225
|
const sessionsDirectory = path.join(codexHome, "sessions");
|
|
59
226
|
const files = walkJsonlFiles(sessionsDirectory)
|
|
60
|
-
.map((filePath) =>
|
|
227
|
+
.map((filePath) => {
|
|
228
|
+
try {
|
|
229
|
+
const stats = fs.statSync(filePath);
|
|
230
|
+
return { filePath, modifiedAt: stats.mtimeMs, size: stats.size };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
throw sessionReadError({ filePath, size: 0 }, error);
|
|
233
|
+
}
|
|
234
|
+
})
|
|
61
235
|
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
62
236
|
if (!files.length) throw new Error(`没有在 ${sessionsDirectory} 找到 Codex 会话记录`);
|
|
63
237
|
return files;
|