codex-work-receipt 0.9.0 → 0.10.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.
- package/CHANGELOG.md +10 -0
- package/README.en.md +13 -3
- package/README.md +13 -3
- package/docs/cli.en.md +40 -2
- package/docs/cli.md +40 -2
- package/docs/codex-skill.en.md +1 -1
- package/docs/codex-skill.md +1 -1
- package/docs/data-schema.en.md +3 -3
- package/docs/data-schema.md +3 -3
- package/docs/privacy.en.md +5 -0
- package/docs/privacy.md +5 -0
- package/package.json +2 -2
- package/skills/ai-work-receipt/SKILL.md +10 -5
- package/src/auto-runner.mjs +4 -4
- package/src/cli.mjs +64 -5
- package/src/core/args.mjs +59 -3
- package/src/core/fact-buckets.mjs +3 -3
- package/src/core/fact-identity.mjs +8 -4
- package/src/core/generator.mjs +15 -3
- package/src/core/metrics.mjs +9 -6
- package/src/core/presentation.mjs +54 -5
- package/src/core/project-identity.mjs +132 -0
- package/src/core/range.mjs +155 -8
- package/src/core/receipt-record.mjs +6 -1
- package/src/core/selector.mjs +146 -21
- package/src/parsers/codex.mjs +52 -7
- package/src/renderers/html.mjs +133 -21
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 === "--
|
|
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 {
|
|
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 (!
|
|
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 || (
|
|
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) {
|
package/src/core/generator.mjs
CHANGED
|
@@ -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(
|
|
43
|
-
|
|
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
|
|
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);
|
package/src/core/metrics.mjs
CHANGED
|
@@ -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
|
-
|
|
5
|
+
isCalendarRange,
|
|
6
6
|
isDateInRange,
|
|
7
|
-
|
|
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 = (!
|
|
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 =
|
|
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 = (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
449
|
-
|
|
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
|
+
}
|