codex-work-receipt 0.7.3 → 0.8.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.
@@ -85,6 +85,15 @@ const RECEIPT_COPY = {
85
85
  exportSuccess: "图片已保存",
86
86
  exportError: "图片生成失败,请重试",
87
87
  receiptAria: "Codex AI 打工小票",
88
+ sidebar: {
89
+ aria: "相关信息",
90
+ supportTitle: "支持项目",
91
+ supportDescription: "喜欢这个工具?",
92
+ changelogTitle: "更新日志",
93
+ changelogLink: "查看更新日志 →",
94
+ sponsorLabel: "赞助伙伴",
95
+ sponsorAlt: "ModelFlare 标志",
96
+ },
88
97
  themes: {
89
98
  classic: "经典白票",
90
99
  diner: "复古粉票",
@@ -127,28 +136,37 @@ const RECEIPT_COPY = {
127
136
  seconds: ["秒", "秒"],
128
137
  },
129
138
  footerThanks: "谢谢惠顾,欢迎明天继续改需求",
130
- transferAria: "传到手机导入联",
131
- transferTitle: "传到手机 · LOCAL TRANSFER",
132
- transferDescription: "主小票保持完整,这一联只负责打开小程序和搬运数据",
133
- openMiniProgram: "1 · 打开小程序",
139
+ transferAria: "微信小程序导入联",
140
+ transferTitle: "导入到微信小程序",
141
+ transferDescription: "下载脱敏数据文件,通过微信聊天文件导入",
142
+ openMiniProgram: "打开小程序",
134
143
  openMiniProgramHint: "微信扫码进入 AI 打工图鉴",
135
- importData: "2 · 扫描导入数据",
136
- importDataHint: "在小程序里点击“从电脑导入”后扫描",
137
- multipartOpenTitle: "先打开小程序",
138
- multipartOpenHint: "数据码将在 {seconds} 秒后自动开始轮播,无需点击电脑",
139
- multipartTransferTitle: "保持手机对准屏幕",
140
- multipartTransferHint: "数据码会自动逐张轮播;漏扫或重复都没关系",
141
- multipartPartLabel: "数据分片 {current}/{total}",
142
- multipartRestart: "重新显示小程序码",
144
+ fileImportTitle: "从聊天文件导入",
145
+ fileImportSteps: [
146
+ "下载微信导入文件",
147
+ "把文件发送到“文件传输助手”或自己的聊天",
148
+ "在小程序中点击“从聊天文件导入”并选择该文件",
149
+ ],
150
+ downloadFile: "下载微信导入文件",
151
+ downloadFileHint: "下载后发送到文件传输助手",
152
+ downloadSuccess: "文件已下载,请发送到微信文件传输助手",
153
+ downloadError: "文件下载失败,请重试",
154
+ fileImportPrivacy: "文件只包含脱敏统计。发送到聊天前,请确认接收方可信。",
155
+ scanAlternativeTitle: "也可以扫码导入",
156
+ scanAlternativeHint: "这份数据可以由一个二维码完整承载",
157
+ showDataQr: "显示数据二维码",
158
+ showMiniProgramCode: "返回小程序码",
159
+ dataQrTitle: "扫描导入数据",
160
+ dataQrHint: "在小程序中选择扫码导入后扫描",
143
161
  dataQrAlt: "当前小票数据二维码",
144
162
  miniProgramAlt: "微信小程序码",
145
163
  exportMiniProgramLabel: "微信扫码打开小程序",
146
164
  placeholderLabel: "小程序码",
147
165
  placeholderValue: "待接入",
148
166
  placeholderAria: "小程序码待接入",
149
- transferNote: "数据码只包含时间、轮次、Token和工具调用等统计,不包含Prompt、回复正文、代码、项目路径或文件名。",
167
+ transferNote: "导入文件和可选数据码只包含时间、轮次、Token 和工具调用等统计,不包含 Prompt、回复正文、代码、项目路径或文件名。",
150
168
  rollingSummaryNotice: "最近 {hours} 小时属于滚动摘要,只保存到私人历史,不参与 AI 供销社统计。需要统计时请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。",
151
- privacy: "结构数据同时保存在本机,未来可以批量导入小程序历史记录。",
169
+ privacy: "结构数据和微信导入文件同时保存在本机;只有你主动发送文件或扫码时,脱敏统计才会离开电脑。",
152
170
  },
153
171
  en: {
154
172
  htmlLang: "en",
@@ -160,6 +178,15 @@ const RECEIPT_COPY = {
160
178
  exportSuccess: "Image saved",
161
179
  exportError: "Could not generate the image. Please try again.",
162
180
  receiptAria: "Codex AI Work Receipt",
181
+ sidebar: {
182
+ aria: "Related information",
183
+ supportTitle: "Support the project",
184
+ supportDescription: "Enjoying this tool?",
185
+ changelogTitle: "Changelog",
186
+ changelogLink: "View the changelog →",
187
+ sponsorLabel: "Sponsored by",
188
+ sponsorAlt: "ModelFlare logo",
189
+ },
163
190
  themes: {
164
191
  classic: "Classic White",
165
192
  diner: "Vintage Pink",
@@ -202,28 +229,37 @@ const RECEIPT_COPY = {
202
229
  seconds: ["sec", "sec"],
203
230
  },
204
231
  footerThanks: "Thanks for your business. More revisions welcome tomorrow.",
205
- transferAria: "Mobile transfer stub",
206
- transferTitle: "TO YOUR PHONE · LOCAL TRANSFER",
207
- transferDescription: "The main receipt stays intact. This stub only opens the mini program and transfers data.",
208
- openMiniProgram: "1 · Open mini program",
232
+ transferAria: "WeChat mini-program import stub",
233
+ transferTitle: "IMPORT INTO WECHAT",
234
+ transferDescription: "Download the privacy-safe data file and import it from a WeChat chat.",
235
+ openMiniProgram: "Open mini program",
209
236
  openMiniProgramHint: "Scan with WeChat to open AI Work Archive",
210
- importData: "2 · Import receipt data",
211
- importDataHint: "Tap “从电脑导入小票” in the mini program, then scan this code",
212
- multipartOpenTitle: "Open the mini program first",
213
- multipartOpenHint: "The data codes will start rotating automatically in {seconds}s. No desktop click is required.",
214
- multipartTransferTitle: "Keep your phone pointed at the screen",
215
- multipartTransferHint: "Data codes rotate automatically. Missed or duplicate scans are safe.",
216
- multipartPartLabel: "Data part {current}/{total}",
217
- multipartRestart: "Show mini-program code again",
237
+ fileImportTitle: "Import from a chat file",
238
+ fileImportSteps: [
239
+ "Download the WeChat import file",
240
+ "Send it to File Transfer or one of your own chats",
241
+ "Tap “Import from chat file” in the mini program and select it",
242
+ ],
243
+ downloadFile: "Download WeChat import file",
244
+ downloadFileHint: "Then send it to WeChat File Transfer",
245
+ downloadSuccess: "File downloaded. Send it to WeChat File Transfer.",
246
+ downloadError: "Could not download the file. Please try again.",
247
+ fileImportPrivacy: "The file contains privacy-safe metrics only. Send it only to a chat you trust.",
248
+ scanAlternativeTitle: "Or import by scanning",
249
+ scanAlternativeHint: "This receipt fits completely in one data QR code.",
250
+ showDataQr: "Show data QR code",
251
+ showMiniProgramCode: "Back to mini-program code",
252
+ dataQrTitle: "Scan to import data",
253
+ dataQrHint: "Choose scan import in the mini program, then scan this code",
218
254
  dataQrAlt: "Current receipt data QR code",
219
255
  miniProgramAlt: "WeChat mini-program code",
220
256
  exportMiniProgramLabel: "Scan to open the mini program",
221
257
  placeholderLabel: "Mini program",
222
258
  placeholderValue: "Pending",
223
259
  placeholderAria: "Mini-program code pending",
224
- transferNote: "The data code contains only statistics such as time, turns, Tokens, and tool calls. It does not contain prompts, responses, code, project paths, or file names.",
260
+ transferNote: "The import file and optional data code contain only statistics such as time, turns, Tokens, and tool calls. They do not contain prompts, responses, code, project paths, or file names.",
225
261
  rollingSummaryNotice: "The last {hours} hours is a rolling summary for private history only. It does not participate in AI Work Cooperative accounting. Use today, this week, the last seven days, or a specific session for accountable facts.",
226
- privacy: "Structured data is also stored locally for future batch import into your mini-program history.",
262
+ privacy: "Structured data and the WeChat import file stay on this computer until you explicitly send the file or scan its data code.",
227
263
  },
228
264
  };
229
265
 
@@ -2,7 +2,9 @@ import crypto from "node:crypto";
2
2
  import zlib from "node:zlib";
3
3
  import QRCode from "qrcode";
4
4
 
5
- import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
5
+ import { compactReceipt } from "./transfer-record.mjs";
6
+
7
+ export { compactReceipt } from "./transfer-record.mjs";
6
8
 
7
9
  const MAX_QR_VERSION = 25;
8
10
  const MAX_MULTIPART_PARTS = 12;
@@ -11,142 +13,6 @@ function checksum(value, length = 8) {
11
13
  return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
12
14
  }
13
15
 
14
- function compactPresentation(record) {
15
- const profileId = record.presentation.work_profile;
16
- const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
17
- const mobileProfile = profileId
18
- ? getWorkProfileCopy(profileId, "zh-CN")
19
- : { title: record.presentation.work_title, review: record.presentation.review };
20
- const mobileCompensation = profileId
21
- ? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
22
- : record.presentation.compensation;
23
-
24
- return {
25
- profileId,
26
- scope,
27
- mobileProfile,
28
- mobileCompensation,
29
- };
30
- }
31
-
32
- function compactBase(record) {
33
- const presentation = compactPresentation(record);
34
- return {
35
- v: record.schema_version,
36
- i: record.id,
37
- g: record.generated_at,
38
- o: presentation.scope,
39
- d: [
40
- record.period.start_at,
41
- record.period.end_at,
42
- record.period.timezone,
43
- record.period.range_start_date || null,
44
- record.period.range_end_date || null,
45
- ],
46
- s: [
47
- record.stats.session_count,
48
- record.stats.completed_turns,
49
- record.stats.user_messages,
50
- record.stats.tool_calls,
51
- record.stats.interruptions,
52
- record.stats.work_duration_ms,
53
- record.stats.average_first_token_ms,
54
- ],
55
- t: [
56
- record.stats.tokens.input_tokens,
57
- record.stats.tokens.cached_input_tokens,
58
- record.stats.tokens.output_tokens,
59
- record.stats.tokens.reasoning_output_tokens,
60
- record.stats.tokens.total_tokens,
61
- ],
62
- m: record.stats.models,
63
- l: record.locale || "zh-CN",
64
- r: presentation.profileId || null,
65
- p: [
66
- record.presentation.default_theme,
67
- presentation.mobileProfile.title,
68
- presentation.mobileProfile.review,
69
- presentation.mobileCompensation
70
- ? [
71
- presentation.mobileCompensation.label,
72
- presentation.mobileCompensation.amount,
73
- presentation.mobileCompensation.unit,
74
- presentation.mobileCompensation.note,
75
- presentation.mobileCompensation.formula_version,
76
- ]
77
- : null,
78
- ],
79
- };
80
- }
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
16
  function encodeCompact(prefix, compact) {
151
17
  const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compact), "utf8"));
152
18
  return `${prefix}.${checksum(compressed)}.${compressed.toString("base64url")}`;
@@ -168,48 +34,11 @@ function fitsVersion(payload, maxVersion) {
168
34
  }
169
35
  }
170
36
 
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 = {}) {
37
+ export function encodeSingleReceiptQr(record, options = {}) {
191
38
  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
- ));
39
+ const payload = encodeReceiptPayload(record);
40
+ if (!fitsVersion(payload, maxVersion)) return null;
41
+ return { payload, version: qrVersion(payload) };
213
42
  }
214
43
 
215
44
  function decodeSinglePayload(payload) {
@@ -14,6 +14,8 @@ import {
14
14
  DEFAULT_LOCALE,
15
15
  getWorkProfileCopy,
16
16
  } from "./presentation.mjs";
17
+ import { createReceiptFile } from "./file-payload.mjs";
18
+ import { writeFileAtomicSync, writeWithAtomicFileSync } from "../lib/files.mjs";
17
19
 
18
20
  const SCHEMA_VERSION = 2;
19
21
  const SOURCE_VERSION = "cwr2";
@@ -127,7 +129,13 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
127
129
  return record;
128
130
  }
129
131
 
130
- export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir = null) {
132
+ export function transferFilePathForOutput(outputHtmlPath) {
133
+ return /\.html?$/i.test(outputHtmlPath)
134
+ ? outputHtmlPath.replace(/\.html?$/i, ".cwr.json")
135
+ : `${outputHtmlPath}.cwr.json`;
136
+ }
137
+
138
+ export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir = null, requestedTransferFile = null) {
131
139
  const dataDir = path.resolve(
132
140
  requestedDataDir || process.env.CODEX_WORK_RECEIPT_HOME || path.join(os.homedir(), ".codex-work-receipt"),
133
141
  );
@@ -135,8 +143,8 @@ export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir =
135
143
  fs.mkdirSync(receiptsDir, { recursive: true });
136
144
 
137
145
  const receiptPath = path.join(receiptsDir, `${record.id}.json`);
138
- fs.writeFileSync(receiptPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
139
- fs.writeFileSync(path.join(dataDir, "latest.json"), `${JSON.stringify(record, null, 2)}\n`, "utf8");
146
+ writeFileAtomicSync(receiptPath, `${JSON.stringify(record, null, 2)}\n`);
147
+ writeFileAtomicSync(path.join(dataDir, "latest.json"), `${JSON.stringify(record, null, 2)}\n`);
140
148
 
141
149
  const allRecords = fs.readdirSync(receiptsDir)
142
150
  .filter((name) => name.endsWith(".json"))
@@ -156,16 +164,16 @@ export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir =
156
164
  const history = [...deduplicated.values()]
157
165
  .sort((left, right) => String(left.period?.end_at).localeCompare(String(right.period?.end_at)));
158
166
  const historyPath = path.join(dataDir, "history.jsonl");
159
- const historyDescriptor = fs.openSync(historyPath, "w");
160
- try {
167
+ writeWithAtomicFileSync(historyPath, (historyDescriptor) => {
161
168
  for (const item of history) fs.writeSync(historyDescriptor, `${JSON.stringify(item)}\n`, null, "utf8");
162
- } finally {
163
- fs.closeSync(historyDescriptor);
164
- }
169
+ });
165
170
 
166
171
  const companionPath = /\.html?$/i.test(outputHtmlPath)
167
172
  ? outputHtmlPath.replace(/\.html?$/i, ".json")
168
173
  : `${outputHtmlPath}.json`;
169
- fs.writeFileSync(companionPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
170
- return { dataDir, receiptPath, companionPath };
174
+ writeFileAtomicSync(companionPath, `${JSON.stringify(record, null, 2)}\n`);
175
+ const transferFile = requestedTransferFile || createReceiptFile(record);
176
+ const transferPath = transferFilePathForOutput(outputHtmlPath);
177
+ writeFileAtomicSync(transferPath, transferFile.content);
178
+ return { dataDir, receiptPath, companionPath, transferPath };
171
179
  }
@@ -0,0 +1,137 @@
1
+ import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
2
+
3
+ function compactPresentation(record) {
4
+ const profileId = record.presentation.work_profile;
5
+ const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
6
+ const mobileProfile = profileId
7
+ ? getWorkProfileCopy(profileId, "zh-CN")
8
+ : { title: record.presentation.work_title, review: record.presentation.review };
9
+ const mobileCompensation = profileId
10
+ ? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
11
+ : record.presentation.compensation;
12
+
13
+ return {
14
+ profileId,
15
+ scope,
16
+ mobileProfile,
17
+ mobileCompensation,
18
+ };
19
+ }
20
+
21
+ function compactBase(record) {
22
+ const presentation = compactPresentation(record);
23
+ return {
24
+ v: record.schema_version,
25
+ i: record.id,
26
+ g: record.generated_at,
27
+ o: presentation.scope,
28
+ d: [
29
+ record.period.start_at,
30
+ record.period.end_at,
31
+ record.period.timezone,
32
+ record.period.range_start_date || null,
33
+ record.period.range_end_date || null,
34
+ ],
35
+ s: [
36
+ record.stats.session_count,
37
+ record.stats.completed_turns,
38
+ record.stats.user_messages,
39
+ record.stats.tool_calls,
40
+ record.stats.interruptions,
41
+ record.stats.work_duration_ms,
42
+ record.stats.average_first_token_ms,
43
+ ],
44
+ t: [
45
+ record.stats.tokens.input_tokens,
46
+ record.stats.tokens.cached_input_tokens,
47
+ record.stats.tokens.output_tokens,
48
+ record.stats.tokens.reasoning_output_tokens,
49
+ record.stats.tokens.total_tokens,
50
+ ],
51
+ m: record.stats.models,
52
+ l: record.locale || "zh-CN",
53
+ r: presentation.profileId || null,
54
+ p: [
55
+ record.presentation.default_theme,
56
+ presentation.mobileProfile.title,
57
+ presentation.mobileProfile.review,
58
+ presentation.mobileCompensation
59
+ ? [
60
+ presentation.mobileCompensation.label,
61
+ presentation.mobileCompensation.amount,
62
+ presentation.mobileCompensation.unit,
63
+ presentation.mobileCompensation.note,
64
+ presentation.mobileCompensation.formula_version,
65
+ ]
66
+ : null,
67
+ ],
68
+ };
69
+ }
70
+
71
+ function compactFact(fact) {
72
+ const stats = fact.stats;
73
+ return [
74
+ fact.fact_id,
75
+ fact.session_id,
76
+ fact.identity_quality,
77
+ fact.source_type,
78
+ fact.local_date,
79
+ fact.bucket_start_at,
80
+ fact.bucket_end_at,
81
+ fact.source_watermark_at,
82
+ fact.observed_at,
83
+ [
84
+ fact.source_revision.kind,
85
+ fact.source_revision.row_count,
86
+ fact.source_revision.byte_length,
87
+ fact.source_revision.tail_hash,
88
+ ],
89
+ fact.content_hash,
90
+ [
91
+ stats.completed_turns,
92
+ stats.user_messages,
93
+ stats.tool_calls,
94
+ stats.interruptions,
95
+ stats.work_duration_ms,
96
+ stats.first_token_total_ms,
97
+ stats.first_token_sample_count,
98
+ stats.input_tokens,
99
+ stats.cached_input_tokens,
100
+ stats.output_tokens,
101
+ stats.reasoning_output_tokens,
102
+ stats.total_tokens,
103
+ stats.token_reset_count,
104
+ stats.models,
105
+ ],
106
+ ];
107
+ }
108
+
109
+ export function compactReceipt(record) {
110
+ const compact = compactBase(record);
111
+ if (record.schema_version !== 2) return compact;
112
+
113
+ const coverage = record.manifest.coverage;
114
+ return {
115
+ ...compact,
116
+ k: record.source.logical_key,
117
+ h: record.source.snapshot_hash,
118
+ a: [
119
+ record.manifest.version,
120
+ record.manifest.fact_schema_version,
121
+ record.manifest.metric_schema_version,
122
+ record.manifest.accounting_timezone,
123
+ record.manifest.fact_count,
124
+ record.manifest.fact_ids,
125
+ [
126
+ coverage.kind,
127
+ coverage.scan_mode,
128
+ coverage.start_date || null,
129
+ coverage.end_date || null,
130
+ coverage.complete_through_date || null,
131
+ coverage.observed_through_at,
132
+ ],
133
+ record.manifest.manifest_hash,
134
+ ],
135
+ f: record.facts.map(compactFact),
136
+ };
137
+ }
@@ -0,0 +1,52 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function stagingPathFor(filePath) {
5
+ return path.join(
6
+ path.dirname(filePath),
7
+ `.${path.basename(filePath)}.write-${process.pid}-${Date.now()}`,
8
+ );
9
+ }
10
+
11
+ function replaceFile(stagingPath, filePath) {
12
+ try {
13
+ fs.renameSync(stagingPath, filePath);
14
+ } catch (error) {
15
+ if (process.platform !== "win32" || !fs.existsSync(filePath)) throw error;
16
+ fs.rmSync(filePath, { force: true });
17
+ fs.renameSync(stagingPath, filePath);
18
+ }
19
+ }
20
+
21
+ export function writeFileAtomicSync(filePath, content, encoding = "utf8") {
22
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
23
+ const stagingPath = stagingPathFor(filePath);
24
+ try {
25
+ fs.writeFileSync(stagingPath, content, encoding);
26
+ replaceFile(stagingPath, filePath);
27
+ } catch (error) {
28
+ fs.rmSync(stagingPath, { force: true });
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ export function writeJsonAtomicSync(filePath, value) {
34
+ writeFileAtomicSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
35
+ }
36
+
37
+ export function writeWithAtomicFileSync(filePath, write) {
38
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
+ const stagingPath = stagingPathFor(filePath);
40
+ let descriptor = null;
41
+ try {
42
+ descriptor = fs.openSync(stagingPath, "w");
43
+ write(descriptor);
44
+ fs.closeSync(descriptor);
45
+ descriptor = null;
46
+ replaceFile(stagingPath, filePath);
47
+ } catch (error) {
48
+ if (descriptor !== null) fs.closeSync(descriptor);
49
+ fs.rmSync(stagingPath, { force: true });
50
+ throw error;
51
+ }
52
+ }
@@ -220,8 +220,8 @@ function sessionFromFile(file) {
220
220
  };
221
221
  }
222
222
 
223
- function codexSessionFiles() {
224
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
223
+ function codexSessionFiles(requestedCodexHome = null) {
224
+ const codexHome = requestedCodexHome || process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
225
225
  const sessionsDirectory = path.join(codexHome, "sessions");
226
226
  const files = walkJsonlFiles(sessionsDirectory)
227
227
  .map((filePath) => {
@@ -275,8 +275,8 @@ export function deduplicateCodexSessions(sessions) {
275
275
  return [...selected.values()];
276
276
  }
277
277
 
278
- export function loadCodexSessions(range) {
279
- const files = codexSessionFiles();
278
+ export function loadCodexSessions(range, { codexHome = null } = {}) {
279
+ const files = codexSessionFiles(codexHome);
280
280
  let candidates = files;
281
281
  let scanMode = "none";
282
282
 
@@ -310,8 +310,8 @@ export function loadCodexSessions(range) {
310
310
  return sessions;
311
311
  }
312
312
 
313
- export function listRecentCodexSessions(limit = 10) {
314
- const sessions = deduplicateCodexSessions(codexSessionFiles()
313
+ export function listRecentCodexSessions(limit = 10, { codexHome = null } = {}) {
314
+ const sessions = deduplicateCodexSessions(codexSessionFiles(codexHome)
315
315
  .slice(0, Math.max(40, limit * 3))
316
316
  .map(sessionFromFile)
317
317
  .filter((session) => session.rows.length))