codex-work-receipt 0.7.2 → 0.7.4

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4
4
+
5
+ - 修复部分 bug。
6
+
7
+ ## 0.7.3
8
+
9
+ - Codex 会话日志改为分块逐行解析,避免超大 `.jsonl` 在转成单个 JavaScript 字符串时触发 V8 长度上限。
10
+ - 会话行解析后立即丢弃 Prompt、回复、图片和工具输出,只在内存中保留统计所需的时间、事件、模型与 Token 字段。
11
+ - 单行异常过大时带文件路径和行号跳过;文件读取失败时补充路径与体积,便于定位具体日志。
12
+ - 本地 `history.jsonl` 改为逐条写入,避免历史增长后形成同类的大字符串拼接风险。
13
+
3
14
  ## 0.7.2
4
15
 
5
16
  - 修复同一 Codex 会话存在多份 append-only 日志修订时生成重复 canonical factId、导致小程序云端入库拒绝的问题。
@@ -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
@@ -10,6 +10,8 @@ AI 打工小票在本机读取 Codex 会话,并在本机完成统计和渲染
10
10
  - 提取事件类型、时间、模型名称和数字统计
11
11
  - 计算轮次、工具调用、Token、时长和中断次数等指标
12
12
 
13
+ 会话日志会在本机分块逐行解析。每行解析后只保留统计所需字段,Prompt、回复、图片和工具输出不会保留在内存统计对象中。
14
+
13
15
  ## 不会写入小票什么
14
16
 
15
17
  - Prompt 和回复正文
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
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/ai-work-receipt.git"
9
+ "url": "git+https://github.com/a-bai-2026/codex-work-receipt.git"
10
10
  },
11
- "homepage": "https://github.com/a-bai-2026/ai-work-receipt#readme",
11
+ "homepage": "https://github.com/a-bai-2026/codex-work-receipt#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/a-bai-2026/ai-work-receipt/issues"
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,56 @@
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
+ const HTML_STAR_LABELS = {
27
+ "zh-CN": "喜欢这个工具?点个 Star ⭐",
28
+ en: "Enjoying it? Star on GitHub ⭐",
29
+ };
30
+
31
+ export function getOpenSourcePrompt(kind = "receipt", locale = "zh-CN") {
32
+ const normalizedLocale = locale === "en" ? "en" : "zh-CN";
33
+ const localized = PROMPTS[normalizedLocale];
34
+ const copy = localized[kind] || localized.receipt;
35
+ return {
36
+ url: OPEN_SOURCE_REPOSITORY_URL,
37
+ label: copy.label,
38
+ message: copy.message,
39
+ separator: normalizedLocale === "en" ? ": " : ":",
40
+ };
41
+ }
42
+
43
+ export function getHtmlStarPrompt(locale = "zh-CN") {
44
+ const normalizedLocale = locale === "en" ? "en" : "zh-CN";
45
+ return {
46
+ url: OPEN_SOURCE_REPOSITORY_URL,
47
+ label: HTML_STAR_LABELS[normalizedLocale],
48
+ };
49
+ }
50
+
51
+ export function printOpenSourcePrompt(kind, locale, writeLine = console.log) {
52
+ const prompt = getOpenSourcePrompt(kind, locale);
53
+ writeLine("");
54
+ writeLine(`${prompt.label}${prompt.separator}${prompt.url}`);
55
+ writeLine(prompt.message);
56
+ }
@@ -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
- fs.writeFileSync(
159
- path.join(dataDir, "history.jsonl"),
160
- history.length ? `${history.map((item) => JSON.stringify(item)).join("\n")}\n` : "",
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")
@@ -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 readJsonl(filePath) {
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 source = fs.readFileSync(filePath, "utf8");
21
- for (const [lineIndex, line] of source.split("\n").entries()) {
22
- if (!line.trim()) continue;
23
- try {
24
- rows.push({ ...JSON.parse(line), __sourceLine: lineIndex + 1 });
25
- } catch {
26
- console.warn(`跳过无法解析的记录:${path.basename(filePath)}:${lineIndex + 1}`);
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
- return { rows, source };
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, source } = readJsonl(file.filePath);
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: sourceBuffer.byteLength,
49
- tail_hash: crypto.createHash("sha256").update(sourceBuffer.subarray(-4096)).digest("hex"),
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) => ({ filePath, modifiedAt: fs.statSync(filePath).mtimeMs }))
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;
@@ -10,6 +10,7 @@ import {
10
10
  getRollingSummaryNotice,
11
11
  getScopeLabel,
12
12
  } from "../core/presentation.mjs";
13
+ import { getHtmlStarPrompt } from "../core/open-source.mjs";
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
  const DOM_TO_IMAGE_SOURCE = fs.readFileSync(require.resolve("dom-to-image-more"), "utf8");
@@ -63,6 +64,7 @@ function formatCopy(template, values) {
63
64
  export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
64
65
  const locale = record.locale || DEFAULT_LOCALE;
65
66
  const copy = getReceiptCopy(locale);
67
+ const githubStarPrompt = getHtmlStarPrompt(locale);
66
68
  const startAt = new Date(record.period.start_at);
67
69
  const endAt = new Date(record.period.end_at);
68
70
  const timezone = record.period.timezone;
@@ -233,10 +235,34 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
233
235
  }
234
236
  .toolbar {
235
237
  display: grid;
238
+ width: 100%;
236
239
  justify-items: center;
237
240
  gap: 12px;
238
241
  margin-bottom: 22px;
239
242
  }
243
+ .github-star-link {
244
+ display: inline-flex;
245
+ justify-self: end;
246
+ align-items: center;
247
+ min-height: 32px;
248
+ padding: 7px 11px;
249
+ border: 1px solid color-mix(in srgb, var(--ink) 38%, transparent);
250
+ border-radius: 999px;
251
+ background: color-mix(in srgb, var(--paper) 78%, transparent);
252
+ color: var(--ink);
253
+ font-size: 11px;
254
+ font-weight: 700;
255
+ line-height: 1.35;
256
+ text-decoration: none;
257
+ transition: background .15s ease, color .15s ease, transform .15s ease;
258
+ }
259
+ .github-star-link:hover,
260
+ .github-star-link:focus-visible {
261
+ background: var(--ink);
262
+ color: var(--paper);
263
+ transform: translateY(-1px);
264
+ }
265
+ .github-star-link:active { transform: translateY(0); }
240
266
  .theme-switcher {
241
267
  display: flex;
242
268
  flex-wrap: wrap;
@@ -515,6 +541,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
515
541
  }
516
542
  @media (max-width: 420px) {
517
543
  .page { padding-inline: 10px; }
544
+ .github-star-link { justify-self: center; text-align: center; }
518
545
  .receipt { padding-inline: 20px; }
519
546
  .meta { grid-template-columns: 1fr; }
520
547
  .meta div:nth-child(even) { text-align: left; }
@@ -525,6 +552,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
525
552
  <body>
526
553
  <main class="page">
527
554
  <div class="toolbar">
555
+ <a class="github-star-link" href="${escapeHtml(githubStarPrompt.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(githubStarPrompt.label)}</a>
528
556
  <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
529
557
  <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
530
558
  <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>