codex-work-receipt 0.9.0 → 0.10.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/src/core/args.mjs CHANGED
@@ -11,16 +11,25 @@ Usage:
11
11
  npx codex-work-receipt@latest --today --lang en
12
12
  npx codex-work-receipt@latest --range last-7-days --lang en
13
13
  npx codex-work-receipt@latest --range this-week --lang en
14
+ npx codex-work-receipt@latest --custom-range --lang en
15
+ npx codex-work-receipt@latest --select-session --lang en
16
+ npx codex-work-receipt@latest --select-project --lang en
14
17
  npx codex-work-receipt@latest --install-skill --lang en
15
18
  npx codex-work-receipt@latest --install-companion --lang en
16
19
  npx codex-work-receipt@latest --setup --lang en
17
20
 
18
21
  Options:
19
- --range <name> Range: latest, last-hours, today, last-7-days, this-week
22
+ --range <name> Range: latest, last-hours, custom-range, today, last-7-days, this-week
20
23
  --hours <number> Summarize the last 1-168 hours
24
+ --custom-range Interactively choose a custom date or time range
25
+ --from <value> Custom start: YYYY-MM-DD or YYYY-MM-DDTHH:mm
26
+ --to <value> Custom end: YYYY-MM-DD or YYYY-MM-DDTHH:mm
21
27
  --latest Summarize the latest active Codex session (default)
22
28
  --today Summarize all Codex activity from today
23
29
  --session <id> Summarize one specific Codex session
30
+ --select-session Interactively choose a recent Codex session
31
+ --project <directory> Limit the receipt to one local project
32
+ --select-project Interactively choose a recent project and range
24
33
  --timezone <name> Use an IANA timezone, for example Asia/Shanghai
25
34
  --lang <name> Receipt language: zh-CN, en
26
35
  --theme <name> Default theme: classic, diner, payroll
@@ -46,16 +55,25 @@ Codex AI 打工小票
46
55
  npx codex-work-receipt@latest --today
47
56
  npx codex-work-receipt@latest --range last-7-days
48
57
  npx codex-work-receipt@latest --range this-week
58
+ npx codex-work-receipt@latest --custom-range
59
+ npx codex-work-receipt@latest --select-session
60
+ npx codex-work-receipt@latest --select-project
49
61
  npx codex-work-receipt@latest --install-skill
50
62
  npx codex-work-receipt@latest --install-companion
51
63
  npx codex-work-receipt@latest --setup
52
64
 
53
65
  选项:
54
- --range <name> 统计范围:latest、last-hours、today、last-7-days、this-week
66
+ --range <name> 统计范围:latest、last-hours、custom-range、today、last-7-days、this-week
55
67
  --hours <number> 统计最近 1~168 小时
68
+ --custom-range 交互选择自定义日期或精确时间区间
69
+ --from <value> 自定义开始:YYYY-MM-DD 或 YYYY-MM-DDTHH:mm
70
+ --to <value> 自定义结束:YYYY-MM-DD 或 YYYY-MM-DDTHH:mm
56
71
  --latest 统计最近活跃的 Codex 会话(默认)
57
72
  --today 统计本地时区今天发生的全部 Codex 活动
58
73
  --session <id> 统计指定的 Codex 会话
74
+ --select-session 交互选择最近的 Codex 会话
75
+ --project <directory> 只统计指定的本地项目目录
76
+ --select-project 交互选择最近项目及统计范围
59
77
  --timezone <name> 指定 IANA 时区,例如 Asia/Shanghai
60
78
  --lang <name> 小票语言:zh-CN、en
61
79
  --theme <name> 默认主题:classic、diner、payroll
@@ -79,7 +97,14 @@ export function parseArgs(argv) {
79
97
  mode: "latest",
80
98
  modeExplicit: false,
81
99
  sessionId: null,
100
+ selectSession: false,
101
+ project: null,
102
+ projectId: null,
103
+ selectProject: false,
82
104
  hours: null,
105
+ customRange: false,
106
+ from: null,
107
+ to: null,
83
108
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
84
109
  locale: "zh-CN",
85
110
  theme: "classic",
@@ -103,7 +128,10 @@ export function parseArgs(argv) {
103
128
  ["--output", "output"],
104
129
  ["--data-dir", "dataDir"],
105
130
  ["--session", "sessionId"],
131
+ ["--project", "project"],
106
132
  ["--hours", "hours"],
133
+ ["--from", "from"],
134
+ ["--to", "to"],
107
135
  ]);
108
136
 
109
137
  for (let index = 0; index < argv.length; index += 1) {
@@ -114,7 +142,13 @@ export function parseArgs(argv) {
114
142
  } else if (argument === "--today") {
115
143
  result.mode = "today";
116
144
  result.modeExplicit = true;
117
- } else if (argument === "--install-pet") result.installPet = true;
145
+ } else if (argument === "--custom-range") {
146
+ result.mode = "custom-range";
147
+ result.modeExplicit = true;
148
+ result.customRange = true;
149
+ } else if (argument === "--select-session") result.selectSession = true;
150
+ else if (argument === "--select-project") result.selectProject = true;
151
+ else if (argument === "--install-pet") result.installPet = true;
118
152
  else if (argument === "--uninstall-pet") result.uninstallPet = true;
119
153
  else if (argument === "--install-companion") result.installCompanion = true;
120
154
  else if (argument === "--range") {
@@ -142,6 +176,10 @@ export function parseArgs(argv) {
142
176
  } else if (key === "hours") {
143
177
  result.mode = "last-hours";
144
178
  result.modeExplicit = true;
179
+ } else if (key === "from" || key === "to") {
180
+ result.mode = "custom-range";
181
+ result.modeExplicit = true;
182
+ result.customRange = true;
145
183
  }
146
184
  } else throw new Error(`不认识的参数:${argument}`);
147
185
  }
@@ -164,6 +202,18 @@ export function parseArgs(argv) {
164
202
  }
165
203
  }
166
204
  if (result.mode === "last-hours" && result.hours === null) result.hours = 3;
205
+ if (result.mode === "custom-range") result.customRange = true;
206
+ if (Boolean(result.from) !== Boolean(result.to)) throw new Error("--from 和 --to 必须同时使用");
207
+ if (result.selectSession && result.selectProject) throw new Error("不能同时选择会话和项目");
208
+ if ((result.selectSession || result.selectProject) && result.modeExplicit) {
209
+ throw new Error("交互选择参数不能与统计范围参数同时使用");
210
+ }
211
+ if (result.selectSession && result.project) throw new Error("指定会话时不能同时指定项目");
212
+ if (result.selectProject && result.project) throw new Error("不能同时使用 --project 和 --select-project");
213
+ if (result.project && !result.modeExplicit) {
214
+ result.mode = "today";
215
+ result.modeExplicit = true;
216
+ }
167
217
  const managementActions = [
168
218
  result.setup,
169
219
  result.enableAuto,
@@ -172,8 +222,14 @@ export function parseArgs(argv) {
172
222
  ].filter(Boolean).length;
173
223
  if (managementActions > 1) throw new Error("自动保存管理参数不能同时使用");
174
224
  if (managementActions && result.modeExplicit) throw new Error("自动保存管理参数不能与统计范围参数同时使用");
225
+ if (managementActions && (result.selectSession || result.selectProject || result.project)) {
226
+ throw new Error("自动保存管理参数不能与会话或项目选择参数同时使用");
227
+ }
175
228
  if (managementActions && (
176
229
  result.installSkill || result.installPet || result.uninstallPet || result.installCompanion
177
230
  )) throw new Error("自动保存管理参数不能与 Skill 或桌宠管理参数同时使用");
231
+ if ((result.selectSession || result.selectProject || result.project) && (
232
+ result.installSkill || result.installPet || result.uninstallPet || result.installCompanion
233
+ )) throw new Error("会话或项目选择参数不能与 Skill 或桌宠管理参数同时使用");
178
234
  return result;
179
235
  }
@@ -7,7 +7,7 @@ import {
7
7
  METRIC_SCHEMA_VERSION,
8
8
  } from "./fact-identity.mjs";
9
9
  import { dateKey, rowDate } from "../lib/time.mjs";
10
- import { isCalendarScope, isDateInRange, shiftDateKey } from "./range.mjs";
10
+ import { isCalendarRange, isDateInRange, shiftDateKey } from "./range.mjs";
11
11
 
12
12
  const TOKEN_KEYS = [
13
13
  "input_tokens",
@@ -112,7 +112,7 @@ function metricsForRows(sessionRows, bucketRows, localDate, timezone) {
112
112
  }
113
113
 
114
114
  function coverageForRange(range, scanMode, observedAt) {
115
- if (!isCalendarScope(range.scope)) {
115
+ if (!isCalendarRange(range)) {
116
116
  return {
117
117
  kind: "selected_sessions",
118
118
  scan_mode: "none",
@@ -151,7 +151,7 @@ export function buildCanonicalFacts(sessions, range, options = {}) {
151
151
 
152
152
  for (const row of sessionRows) {
153
153
  const date = rowDate(row);
154
- if (!date || (isCalendarScope(range.scope) && !isDateInRange(date, range))) continue;
154
+ if (!date || (isCalendarRange(range) && !isDateInRange(date, range))) continue;
155
155
  const localDate = dateKey(date, accountingTimezone);
156
156
  const rows = buckets.get(localDate) || [];
157
157
  rows.push(row);
@@ -25,16 +25,20 @@ export function buildLogicalReceiptKey(metrics) {
25
25
  const sessionPart = stableId("cwg", "codex-work-receipt/session-group/v1", [
26
26
  ...[...metrics.sessionIds].sort(),
27
27
  ]);
28
+ const projectPart = metrics.projectId ? `:project:${metrics.projectId}` : "";
28
29
  if (metrics.mode === "latest" || metrics.mode === "session") {
29
- return `${metrics.mode}:${sessionPart}:${metrics.timezone}`;
30
+ return `${metrics.mode}:${sessionPart}:${metrics.timezone}${projectPart}`;
30
31
  }
31
32
  if (metrics.mode === "last-hours") {
32
- return `last-hours:${metrics.windowHours}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}`;
33
+ return `last-hours:${metrics.windowHours}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}${projectPart}`;
34
+ }
35
+ if (metrics.mode === "custom-range") {
36
+ return `custom-range:${metrics.boundaryKind}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}${projectPart}`;
33
37
  }
34
38
  if (metrics.mode === "this-week") {
35
- return `this-week:${metrics.rangeStartDate}:${metrics.timezone}`;
39
+ return `this-week:${metrics.rangeStartDate}:${metrics.timezone}${projectPart}`;
36
40
  }
37
- return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
41
+ return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}${projectPart}`;
38
42
  }
39
43
 
40
44
  export function buildSummaryReceiptId(logicalReceiptKey) {
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { buildCanonicalFacts } from "./fact-buckets.mjs";
5
5
  import { createReceiptFile } from "./file-payload.mjs";
6
6
  import { collectMetrics } from "./metrics.mjs";
7
+ import { getProjectIdentitySecret } from "./project-identity.mjs";
7
8
  import { outputSlugForRange, resolveRange } from "./range.mjs";
8
9
  import {
9
10
  buildReceiptRecord,
@@ -39,11 +40,22 @@ export async function generateReceipt(
39
40
  ) {
40
41
  if (!projectDir) throw new Error("生成小票时缺少项目目录");
41
42
 
42
- const range = resolveRange(options.mode, options.timezone, now, options.sessionId, options.hours);
43
- const sessions = loadCodexSessions(range, { codexHome });
43
+ const range = resolveRange(
44
+ options.mode,
45
+ options.timezone,
46
+ now,
47
+ options.sessionId,
48
+ options.hours,
49
+ options.mode === "custom-range" ? { from: options.from, to: options.to } : null,
50
+ options.projectId,
51
+ );
52
+ const projectSecret = range.projectId ? getProjectIdentitySecret({ dataDir: options.dataDir }) : null;
53
+ const sessions = loadCodexSessions(range, { codexHome, projectSecret });
44
54
  const metrics = collectMetrics(sessions, range);
45
55
  const observedAt = now.toISOString();
46
- const canonical = range.scope === "last-hours"
56
+ const summaryOnly = range.scope === "last-hours"
57
+ || (range.scope === "custom-range" && range.boundaryKind === "exact-time");
58
+ const canonical = summaryOnly
47
59
  ? {}
48
60
  : buildCanonicalFacts(sessions, range, { observedAt });
49
61
  const record = buildReceiptRecord(metrics, options.theme, options.locale, canonical);
@@ -2,9 +2,9 @@ import { dateKey, rowDate } from "../lib/time.mjs";
2
2
  import { toolCategoryForRow } from "../lib/tool-category.mjs";
3
3
  import {
4
4
  calendarDayCount,
5
- isCalendarScope,
5
+ isCalendarRange,
6
6
  isDateInRange,
7
- isTimeWindowScope,
7
+ isTimeWindowRange,
8
8
  } from "./range.mjs";
9
9
  import { selectWorkProfileId } from "./presentation.mjs";
10
10
 
@@ -111,7 +111,7 @@ function sessionTokenUsage(rows, range) {
111
111
  for (const row of events) {
112
112
  const currentUsage = row.payload.info.total_token_usage;
113
113
  const date = rowDate(row);
114
- const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
114
+ const selected = (!isCalendarRange(range) && !isTimeWindowRange(range))
115
115
  || isDateInRange(date, range);
116
116
 
117
117
  for (const key of Object.keys(totals)) {
@@ -141,6 +141,7 @@ function calculateWorkPoints(metrics) {
141
141
 
142
142
  function emptyRangeMessage(range) {
143
143
  if (range.scope === "last-hours") return `最近 ${range.hours} 小时没有找到 Codex 活动`;
144
+ if (range.scope === "custom-range") return "自定义时间区间内没有找到 Codex 活动";
144
145
  if (range.scope === "today") return `没有找到 ${range.targetDate} 的 Codex 活动`;
145
146
  if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
146
147
  if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
@@ -169,7 +170,7 @@ export function collectMetrics(sessions, range) {
169
170
  const activityByHour = Array(24).fill(0);
170
171
 
171
172
  for (const session of sessions) {
172
- const scopedRows = isCalendarScope(range.scope) || isTimeWindowScope(range.scope)
173
+ const scopedRows = isCalendarRange(range) || isTimeWindowRange(range)
173
174
  ? session.rows.filter((row) => isDateInRange(rowDate(row), range))
174
175
  : session.rows;
175
176
 
@@ -183,7 +184,7 @@ export function collectMetrics(sessions, range) {
183
184
  let sessionHasSelectedModel = false;
184
185
  for (const row of session.rows) {
185
186
  if (row.type === "turn_context" && row.payload?.model) activeModel = row.payload.model;
186
- const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
187
+ const selected = (!isCalendarRange(range) && !isTimeWindowRange(range))
187
188
  || isDateInRange(rowDate(row), range);
188
189
  if (!selected) continue;
189
190
  const date = rowDate(row);
@@ -258,7 +259,9 @@ export function collectMetrics(sessions, range) {
258
259
  windowStartAt: range.startAt,
259
260
  windowEndAt: range.endAt,
260
261
  windowHours: range.hours,
261
- calendarDayCount: isCalendarScope(range.scope) ? calendarDayCount(range) : 1,
262
+ boundaryKind: range.boundaryKind,
263
+ projectId: range.projectId,
264
+ calendarDayCount: isCalendarRange(range) ? calendarDayCount(range) : 1,
262
265
  activeDayCount: Math.max(1, activeDateKeys.size),
263
266
  sessionIds,
264
267
  sessionCount: scopedSessions.length,
@@ -102,18 +102,30 @@ const RECEIPT_COPY = {
102
102
  copyErrorLabel: "复制失败",
103
103
  copiedStatus: "命令已复制到剪贴板",
104
104
  copyErrorStatus: "无法自动复制,请手动选择命令",
105
+ tabAria: "小票功能分类",
105
106
  groups: [
106
107
  {
107
- title: "生成小票",
108
+ id: "time",
109
+ title: "时间范围",
108
110
  commands: [
109
111
  { label: "生成最近一次小票", args: "--latest" },
110
112
  { label: "生成最近 3 小时小票", args: "--hours 3" },
111
113
  { label: "生成今日小票", args: "--today" },
112
114
  { label: "生成最近 7 天小票", args: "--range last-7-days" },
113
115
  { label: "生成本周小票", args: "--range this-week" },
116
+ { label: "自定义时间区间", args: "--custom-range" },
114
117
  ],
115
118
  },
116
119
  {
120
+ id: "selection",
121
+ title: "会话与项目",
122
+ commands: [
123
+ { label: "选择指定会话", args: "--select-session" },
124
+ { label: "选择指定项目", args: "--select-project" },
125
+ ],
126
+ },
127
+ {
128
+ id: "automation",
117
129
  title: "自动与手动",
118
130
  commands: [
119
131
  { label: "重新选择工作模式", args: "--setup" },
@@ -123,6 +135,7 @@ const RECEIPT_COPY = {
123
135
  ],
124
136
  },
125
137
  {
138
+ id: "companion",
126
139
  title: "票仔与对话开票",
127
140
  commands: [
128
141
  { label: "安装票仔和对话开票", args: "--install-companion" },
@@ -142,6 +155,9 @@ const RECEIPT_COPY = {
142
155
  latest: "最近一次会话",
143
156
  session: "指定会话",
144
157
  "last-hours": "最近 {hours} 小时",
158
+ "custom-range-calendar-days": "自定义日期",
159
+ "custom-range-exact-time": "自定义时间",
160
+ "custom-range": "自定义区间",
145
161
  today: "今日全部会话",
146
162
  "last-7-days": "最近 7 个自然日",
147
163
  "this-week": "本周全部会话",
@@ -235,6 +251,8 @@ const RECEIPT_COPY = {
235
251
  placeholderAria: "小程序码待接入",
236
252
  transferNote: "导入文件和可选数据码只包含时间、轮次、Token 和工具调用等统计,不包含 Prompt、回复正文、代码、项目路径或文件名。",
237
253
  rollingSummaryNotice: "最近 {hours} 小时属于滚动摘要,只保存到私人历史,不参与 AI 供销社统计。需要统计时请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。",
254
+ customSummaryNotice: "精确时间区间属于私人摘要,不参与 AI 供销社统计。按自然日选择自定义区间时可以生成可去重的规范事实。",
255
+ projectScopeTemplate: "指定项目 · {scope}",
238
256
  privacy: "结构数据和微信导入文件同时保存在本机;只有你主动发送文件或扫码时,脱敏统计才会离开电脑。",
239
257
  },
240
258
  en: {
@@ -264,18 +282,30 @@ const RECEIPT_COPY = {
264
282
  copyErrorLabel: "Copy failed",
265
283
  copiedStatus: "Command copied to the clipboard",
266
284
  copyErrorStatus: "Could not copy automatically. Select the command manually.",
285
+ tabAria: "Receipt feature categories",
267
286
  groups: [
268
287
  {
269
- title: "Generate receipts",
288
+ id: "time",
289
+ title: "Time ranges",
270
290
  commands: [
271
291
  { label: "Generate the latest receipt", args: "--latest" },
272
292
  { label: "Generate the last 3 hours", args: "--hours 3" },
273
293
  { label: "Generate today's receipt", args: "--today" },
274
294
  { label: "Generate the last 7 days", args: "--range last-7-days" },
275
295
  { label: "Generate this week's receipt", args: "--range this-week" },
296
+ { label: "Choose a custom range", args: "--custom-range" },
297
+ ],
298
+ },
299
+ {
300
+ id: "selection",
301
+ title: "Sessions and projects",
302
+ commands: [
303
+ { label: "Choose a specific session", args: "--select-session" },
304
+ { label: "Choose a specific project", args: "--select-project" },
276
305
  ],
277
306
  },
278
307
  {
308
+ id: "automation",
279
309
  title: "Automatic and manual",
280
310
  commands: [
281
311
  { label: "Choose a working mode", args: "--setup" },
@@ -285,6 +315,7 @@ const RECEIPT_COPY = {
285
315
  ],
286
316
  },
287
317
  {
318
+ id: "companion",
288
319
  title: "Ticket Buddy and chat commands",
289
320
  commands: [
290
321
  { label: "Install Ticket Buddy and chat commands", args: "--install-companion" },
@@ -304,6 +335,9 @@ const RECEIPT_COPY = {
304
335
  latest: "Latest session",
305
336
  session: "Selected session",
306
337
  "last-hours": "Last {hours} hours",
338
+ "custom-range-calendar-days": "Custom dates",
339
+ "custom-range-exact-time": "Custom time range",
340
+ "custom-range": "Custom range",
307
341
  today: "All sessions today",
308
342
  "last-7-days": "Last 7 calendar days",
309
343
  "this-week": "All sessions this week",
@@ -397,6 +431,8 @@ const RECEIPT_COPY = {
397
431
  placeholderAria: "Mini-program code pending",
398
432
  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.",
399
433
  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.",
434
+ customSummaryNotice: "An exact time range is a private summary and does not participate in AI Work Cooperative accounting. Choose whole calendar dates to create deduplicated canonical facts.",
435
+ projectScopeTemplate: "Selected project · {scope}",
400
436
  privacy: "Structured data and the WeChat import file stay on this computer until you explicitly send the file or scan its data code.",
401
437
  },
402
438
  };
@@ -406,6 +442,7 @@ const COMPENSATION_COPY = {
406
442
  latest: "本单工资",
407
443
  session: "本单工资",
408
444
  "last-hours": "本段工资",
445
+ "custom-range": "区间工资",
409
446
  today: "本日工资",
410
447
  "last-7-days": "近七日工资",
411
448
  "this-week": "本周工资",
@@ -416,6 +453,7 @@ const COMPENSATION_COPY = {
416
453
  latest: "SHIFT PAY",
417
454
  session: "SHIFT PAY",
418
455
  "last-hours": "WINDOW PAY",
456
+ "custom-range": "RANGE PAY",
419
457
  today: "TODAY'S PAY",
420
458
  "last-7-days": "7-DAY PAY",
421
459
  "this-week": "THIS WEEK'S PAY",
@@ -444,15 +482,26 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
444
482
  return RECEIPT_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
445
483
  }
446
484
 
447
- export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null) {
448
- const template = getReceiptCopy(locale).scope[scope] || getReceiptCopy(locale).scope.latest;
449
- return String(template).replaceAll("{hours}", String(hours || 3));
485
+ export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null, options = {}) {
486
+ const copy = getReceiptCopy(locale);
487
+ const scopeKey = scope === "custom-range" && options.rangeKind
488
+ ? `custom-range-${options.rangeKind}`
489
+ : scope;
490
+ const template = copy.scope[scopeKey] || copy.scope[scope] || copy.scope.latest;
491
+ const label = String(template).replaceAll("{hours}", String(hours || 3));
492
+ return options.filterKind === "project"
493
+ ? String(copy.projectScopeTemplate).replaceAll("{scope}", label)
494
+ : label;
450
495
  }
451
496
 
452
497
  export function getRollingSummaryNotice(locale = DEFAULT_LOCALE, hours = null) {
453
498
  return String(getReceiptCopy(locale).rollingSummaryNotice).replaceAll("{hours}", String(hours || 3));
454
499
  }
455
500
 
501
+ export function getCustomSummaryNotice(locale = DEFAULT_LOCALE) {
502
+ return getReceiptCopy(locale).customSummaryNotice;
503
+ }
504
+
456
505
  export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
457
506
  const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
458
507
  return {
@@ -0,0 +1,132 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { writeFileAtomicSync } from "../lib/files.mjs";
7
+
8
+ const PROJECT_KEY_FILENAME = "project-identity.key";
9
+ const PROJECT_ID_DOMAIN = "codex-work-receipt/project/v1";
10
+
11
+ function workReceiptHome(requestedDataDir = null) {
12
+ return path.resolve(
13
+ requestedDataDir || process.env.CODEX_WORK_RECEIPT_HOME || path.join(os.homedir(), ".codex-work-receipt"),
14
+ );
15
+ }
16
+
17
+ function normalizeRepositoryUrl(value) {
18
+ return String(value || "")
19
+ .trim()
20
+ .replace(/\\/g, "/")
21
+ .replace(/^git@([^:]+):/i, "$1/")
22
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?/i, "")
23
+ .replace(/\.git\/?$/i, "")
24
+ .replace(/\/+$/, "")
25
+ .toLowerCase();
26
+ }
27
+
28
+ function projectLabelFromRepositoryUrl(value) {
29
+ const normalized = String(value || "").trim().replace(/\\/g, "/").replace(/\.git\/?$/i, "");
30
+ const segment = normalized.split(/[/:]/).filter(Boolean).at(-1);
31
+ return segment || null;
32
+ }
33
+
34
+ function normalizeWorkingDirectory(value) {
35
+ const resolved = path.resolve(String(value || "").trim());
36
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
37
+ }
38
+
39
+ function projectIdForIdentity(identity, secret) {
40
+ const digest = crypto
41
+ .createHmac("sha256", secret)
42
+ .update(`${PROJECT_ID_DOMAIN}\0${identity}`)
43
+ .digest("hex");
44
+ return `cwp_${digest}`;
45
+ }
46
+
47
+ function gitDirectoryForProject(projectPath) {
48
+ let current = projectPath;
49
+ while (true) {
50
+ const candidate = path.join(current, ".git");
51
+ if (fs.existsSync(candidate)) {
52
+ const stats = fs.statSync(candidate);
53
+ if (stats.isDirectory()) return candidate;
54
+ if (stats.isFile()) {
55
+ const match = /^gitdir:\s*(.+)$/im.exec(fs.readFileSync(candidate, "utf8"));
56
+ if (match) return path.resolve(current, match[1].trim());
57
+ }
58
+ }
59
+ const parent = path.dirname(current);
60
+ if (parent === current) return null;
61
+ current = parent;
62
+ }
63
+ }
64
+
65
+ function repositoryUrlFromConfig(gitDirectory) {
66
+ if (!gitDirectory) return null;
67
+ let configPath = path.join(gitDirectory, "config");
68
+ if (!fs.existsSync(configPath)) {
69
+ const commonDirPath = path.join(gitDirectory, "commondir");
70
+ if (fs.existsSync(commonDirPath)) {
71
+ configPath = path.join(path.resolve(gitDirectory, fs.readFileSync(commonDirPath, "utf8").trim()), "config");
72
+ }
73
+ }
74
+ if (!fs.existsSync(configPath)) return null;
75
+ const config = fs.readFileSync(configPath, "utf8");
76
+ const origin = /\[remote\s+"origin"\]([\s\S]*?)(?=\n\s*\[|$)/i.exec(config)?.[1] || "";
77
+ return /^\s*url\s*=\s*(.+)$/im.exec(origin)?.[1]?.trim() || null;
78
+ }
79
+
80
+ export function getProjectIdentitySecret({ dataDir = null } = {}) {
81
+ const directory = workReceiptHome(dataDir);
82
+ const keyPath = path.join(directory, PROJECT_KEY_FILENAME);
83
+ fs.mkdirSync(directory, { recursive: true });
84
+ if (fs.existsSync(keyPath)) {
85
+ const value = fs.readFileSync(keyPath, "utf8").trim();
86
+ if (/^[a-f0-9]{64}$/i.test(value)) return Buffer.from(value, "hex");
87
+ throw new Error(`项目身份密钥无效:${keyPath}`);
88
+ }
89
+
90
+ const secret = crypto.randomBytes(32);
91
+ writeFileAtomicSync(keyPath, `${secret.toString("hex")}\n`);
92
+ try { fs.chmodSync(keyPath, 0o600); } catch {}
93
+ return secret;
94
+ }
95
+
96
+ export function projectDescriptorFromSessionMeta(payload, secret) {
97
+ if (!secret || !payload || typeof payload !== "object") return null;
98
+ const repositoryUrl = typeof payload.git?.repository_url === "string"
99
+ ? payload.git.repository_url
100
+ : null;
101
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
102
+ if (!repositoryUrl && !cwd) return null;
103
+
104
+ const identity = repositoryUrl
105
+ ? `repository:${normalizeRepositoryUrl(repositoryUrl)}`
106
+ : `directory:${normalizeWorkingDirectory(cwd)}`;
107
+ const label = repositoryUrl
108
+ ? projectLabelFromRepositoryUrl(repositoryUrl)
109
+ : path.basename(normalizeWorkingDirectory(cwd));
110
+ return {
111
+ projectId: projectIdForIdentity(identity, secret),
112
+ projectLabel: label || "Codex project",
113
+ };
114
+ }
115
+
116
+ export function projectDescriptorFromPath(value, secret) {
117
+ if (!secret) throw new Error("指定项目时缺少本地项目身份密钥");
118
+ const projectPath = normalizeWorkingDirectory(value);
119
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
120
+ throw new Error(`项目目录不存在:${value}`);
121
+ }
122
+ const repositoryUrl = repositoryUrlFromConfig(gitDirectoryForProject(projectPath));
123
+ const identity = repositoryUrl
124
+ ? `repository:${normalizeRepositoryUrl(repositoryUrl)}`
125
+ : `directory:${projectPath}`;
126
+ return {
127
+ projectId: projectIdForIdentity(identity, secret),
128
+ projectLabel: repositoryUrl
129
+ ? projectLabelFromRepositoryUrl(repositoryUrl)
130
+ : path.basename(projectPath),
131
+ };
132
+ }