codex-work-receipt 0.3.0 → 0.5.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 +17 -0
- package/README.en.md +28 -111
- package/README.md +29 -189
- package/docs/cli.en.md +137 -0
- package/docs/cli.md +137 -0
- package/docs/codex-skill.en.md +45 -0
- package/docs/codex-skill.md +45 -0
- package/docs/data-schema.en.md +35 -0
- package/docs/data-schema.md +19 -9
- package/docs/images/social-preview-source.svg +84 -0
- package/docs/images/social-preview.png +0 -0
- package/docs/mobile-import.en.md +19 -0
- package/docs/mobile-import.md +13 -9
- package/docs/privacy.en.md +30 -0
- package/docs/privacy.md +26 -8
- package/package.json +2 -1
- package/skills/ai-work-receipt/SKILL.md +5 -2
- package/src/cli.mjs +23 -4
- package/src/core/args.mjs +35 -4
- package/src/core/metrics.mjs +37 -25
- package/src/core/presentation.mjs +36 -13
- package/src/core/qr-payload.mjs +10 -2
- package/src/core/range.mjs +118 -0
- package/src/core/receipt-record.mjs +10 -3
- package/src/core/selector.mjs +71 -0
- package/src/parsers/codex.mjs +77 -11
- package/src/renderers/html.mjs +191 -40
- package/templates/README.md +3 -4
package/src/core/args.mjs
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
+
import { normalizeScope } from "./range.mjs";
|
|
2
|
+
|
|
1
3
|
export function printHelp(locale = "zh-CN") {
|
|
2
4
|
console.log(locale === "en" ? `
|
|
3
5
|
Codex AI Work Receipt
|
|
4
6
|
|
|
5
7
|
Usage:
|
|
8
|
+
npx codex-work-receipt@latest
|
|
6
9
|
npx codex-work-receipt@latest --latest --lang en
|
|
7
10
|
npx codex-work-receipt@latest --today --lang en
|
|
11
|
+
npx codex-work-receipt@latest --range last-7-days --lang en
|
|
12
|
+
npx codex-work-receipt@latest --range this-week --lang en
|
|
8
13
|
npx codex-work-receipt@latest --install-skill --lang en
|
|
9
14
|
|
|
10
15
|
Options:
|
|
16
|
+
--range <name> Range: latest, today, last-7-days, this-week
|
|
11
17
|
--latest Summarize the latest active Codex session (default)
|
|
12
18
|
--today Summarize all Codex activity from today
|
|
19
|
+
--session <id> Summarize one specific Codex session
|
|
13
20
|
--timezone <name> Use an IANA timezone, for example Asia/Shanghai
|
|
14
21
|
--lang <name> Receipt language: zh-CN, en
|
|
15
22
|
--theme <name> Default theme: classic, diner, payroll
|
|
@@ -22,13 +29,18 @@ Options:
|
|
|
22
29
|
Codex AI 打工小票
|
|
23
30
|
|
|
24
31
|
用法:
|
|
32
|
+
npx codex-work-receipt@latest
|
|
25
33
|
npx codex-work-receipt@latest --latest
|
|
26
34
|
npx codex-work-receipt@latest --today
|
|
35
|
+
npx codex-work-receipt@latest --range last-7-days
|
|
36
|
+
npx codex-work-receipt@latest --range this-week
|
|
27
37
|
npx codex-work-receipt@latest --install-skill
|
|
28
38
|
|
|
29
39
|
选项:
|
|
40
|
+
--range <name> 统计范围:latest、today、last-7-days、this-week
|
|
30
41
|
--latest 统计最近活跃的 Codex 会话(默认)
|
|
31
42
|
--today 统计本地时区今天发生的全部 Codex 活动
|
|
43
|
+
--session <id> 统计指定的 Codex 会话
|
|
32
44
|
--timezone <name> 指定 IANA 时区,例如 Asia/Shanghai
|
|
33
45
|
--lang <name> 小票语言:zh-CN、en
|
|
34
46
|
--theme <name> 默认主题:classic、diner、payroll
|
|
@@ -43,6 +55,8 @@ Codex AI 打工小票
|
|
|
43
55
|
export function parseArgs(argv) {
|
|
44
56
|
const result = {
|
|
45
57
|
mode: "latest",
|
|
58
|
+
modeExplicit: false,
|
|
59
|
+
sessionId: null,
|
|
46
60
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
|
|
47
61
|
locale: "zh-CN",
|
|
48
62
|
theme: "classic",
|
|
@@ -58,19 +72,36 @@ export function parseArgs(argv) {
|
|
|
58
72
|
["--theme", "theme"],
|
|
59
73
|
["--output", "output"],
|
|
60
74
|
["--data-dir", "dataDir"],
|
|
75
|
+
["--session", "sessionId"],
|
|
61
76
|
]);
|
|
62
77
|
|
|
63
78
|
for (let index = 0; index < argv.length; index += 1) {
|
|
64
79
|
const argument = argv[index];
|
|
65
|
-
if (argument === "--latest")
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
if (argument === "--latest") {
|
|
81
|
+
result.mode = "latest";
|
|
82
|
+
result.modeExplicit = true;
|
|
83
|
+
} else if (argument === "--today") {
|
|
84
|
+
result.mode = "today";
|
|
85
|
+
result.modeExplicit = true;
|
|
86
|
+
} else if (argument === "--range") {
|
|
87
|
+
const value = argv[++index];
|
|
88
|
+
if (!value) throw new Error("--range 需要提供值");
|
|
89
|
+
const scope = normalizeScope(value);
|
|
90
|
+
if (!scope || scope === "session") throw new Error(`不支持的统计范围:${value}`);
|
|
91
|
+
result.mode = scope;
|
|
92
|
+
result.modeExplicit = true;
|
|
93
|
+
} else if (argument === "--install-skill") result.installSkill = true;
|
|
68
94
|
else if (argument === "--no-open") result.open = false;
|
|
69
95
|
else if (argument === "--help" || argument === "-h") result.help = true;
|
|
70
96
|
else if (optionsWithValues.has(argument)) {
|
|
71
97
|
const value = argv[++index];
|
|
72
98
|
if (!value) throw new Error(`${argument} 需要提供值`);
|
|
73
|
-
|
|
99
|
+
const key = optionsWithValues.get(argument);
|
|
100
|
+
result[key] = value;
|
|
101
|
+
if (key === "sessionId") {
|
|
102
|
+
result.mode = "session";
|
|
103
|
+
result.modeExplicit = true;
|
|
104
|
+
}
|
|
74
105
|
} else throw new Error(`不认识的参数:${argument}`);
|
|
75
106
|
}
|
|
76
107
|
|
package/src/core/metrics.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dateKey, rowDate } from "../lib/time.mjs";
|
|
2
|
+
import { calendarDayCount, isCalendarScope, isDateInRange } from "./range.mjs";
|
|
2
3
|
import { selectWorkProfileId } from "./presentation.mjs";
|
|
3
4
|
|
|
4
5
|
function zeroUsage() {
|
|
@@ -23,26 +24,26 @@ function addUsage(target, source) {
|
|
|
23
24
|
for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function sessionTokenUsage(rows,
|
|
27
|
+
function sessionTokenUsage(rows, range) {
|
|
27
28
|
const events = rows
|
|
28
29
|
.filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
|
|
29
30
|
.filter((row) => row.payload?.info?.total_token_usage)
|
|
30
31
|
.sort((left, right) => (rowDate(left)?.getTime() || 0) - (rowDate(right)?.getTime() || 0));
|
|
31
32
|
|
|
32
33
|
if (!events.length) return zeroUsage();
|
|
33
|
-
if (
|
|
34
|
+
if (!isCalendarScope(range.scope)) {
|
|
35
|
+
return { ...zeroUsage(), ...events.at(-1).payload.info.total_token_usage };
|
|
36
|
+
}
|
|
34
37
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
return date && dateKey(date, timezone) === targetDate;
|
|
38
|
-
}).at(-1);
|
|
39
|
-
if (!lastToday) return zeroUsage();
|
|
38
|
+
const lastInRange = events.filter((row) => isDateInRange(rowDate(row), range)).at(-1);
|
|
39
|
+
if (!lastInRange) return zeroUsage();
|
|
40
40
|
|
|
41
41
|
const baseline = events.filter((row) => {
|
|
42
42
|
const date = rowDate(row);
|
|
43
|
-
return date && dateKey(date, timezone) <
|
|
43
|
+
return date && dateKey(date, range.timezone) < range.startDate;
|
|
44
44
|
}).at(-1)?.payload.info.total_token_usage || zeroUsage();
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
return subtractUsage(lastInRange.payload.info.total_token_usage, baseline);
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
function calculateWorkPoints(metrics) {
|
|
@@ -60,8 +61,14 @@ function calculateWorkPoints(metrics) {
|
|
|
60
61
|
return Math.max(0, Math.round(points));
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
function emptyRangeMessage(range) {
|
|
65
|
+
if (range.scope === "today") return `没有找到 ${range.targetDate} 的 Codex 活动`;
|
|
66
|
+
if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
|
|
67
|
+
if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
|
|
68
|
+
return "没有找到可统计的 Codex 会话";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function collectMetrics(sessions, range) {
|
|
65
72
|
const scopedSessions = [];
|
|
66
73
|
const sessionIds = [];
|
|
67
74
|
const tokens = zeroUsage();
|
|
@@ -73,24 +80,25 @@ export function collectMetrics(sessions, mode, timezone) {
|
|
|
73
80
|
let totalFirstTokenMs = 0;
|
|
74
81
|
let firstTokenSamples = 0;
|
|
75
82
|
const timestamps = [];
|
|
83
|
+
const activeDateKeys = new Set();
|
|
76
84
|
const models = new Set();
|
|
77
85
|
|
|
78
86
|
for (const session of sessions) {
|
|
79
|
-
const scopedRows =
|
|
80
|
-
? session.rows
|
|
81
|
-
: session.rows
|
|
82
|
-
const date = rowDate(row);
|
|
83
|
-
return date && dateKey(date, timezone) === targetDate;
|
|
84
|
-
});
|
|
87
|
+
const scopedRows = isCalendarScope(range.scope)
|
|
88
|
+
? session.rows.filter((row) => isDateInRange(rowDate(row), range))
|
|
89
|
+
: session.rows;
|
|
85
90
|
|
|
86
91
|
if (!scopedRows.length) continue;
|
|
87
92
|
scopedSessions.push(session);
|
|
88
93
|
sessionIds.push(session.sessionId);
|
|
89
|
-
addUsage(tokens, sessionTokenUsage(session.rows,
|
|
94
|
+
addUsage(tokens, sessionTokenUsage(session.rows, range));
|
|
90
95
|
|
|
91
96
|
for (const row of scopedRows) {
|
|
92
97
|
const date = rowDate(row);
|
|
93
|
-
if (date)
|
|
98
|
+
if (date) {
|
|
99
|
+
timestamps.push(date);
|
|
100
|
+
activeDateKeys.add(dateKey(date, range.timezone));
|
|
101
|
+
}
|
|
94
102
|
|
|
95
103
|
if (row.type === "turn_context" && row.payload?.model) models.add(row.payload.model);
|
|
96
104
|
if (row.type === "event_msg") {
|
|
@@ -121,15 +129,19 @@ export function collectMetrics(sessions, mode, timezone) {
|
|
|
121
129
|
if (fallbackModel) models.add(fallbackModel);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
if (!scopedSessions.length)
|
|
125
|
-
throw new Error(mode === "today" ? `没有找到 ${targetDate} 的 Codex 活动` : "没有找到可统计的 Codex 会话");
|
|
126
|
-
}
|
|
132
|
+
if (!scopedSessions.length || !timestamps.length) throw new Error(emptyRangeMessage(range));
|
|
127
133
|
|
|
128
134
|
timestamps.sort((left, right) => left - right);
|
|
135
|
+
const rangeStartDate = range.startDate || dateKey(timestamps[0], range.timezone);
|
|
136
|
+
const rangeEndDate = range.endDate || dateKey(timestamps.at(-1), range.timezone);
|
|
129
137
|
const metrics = {
|
|
130
|
-
mode,
|
|
131
|
-
timezone,
|
|
132
|
-
targetDate,
|
|
138
|
+
mode: range.scope,
|
|
139
|
+
timezone: range.timezone,
|
|
140
|
+
targetDate: range.targetDate,
|
|
141
|
+
rangeStartDate,
|
|
142
|
+
rangeEndDate,
|
|
143
|
+
calendarDayCount: isCalendarScope(range.scope) ? calendarDayCount(range) : 1,
|
|
144
|
+
activeDayCount: Math.max(1, activeDateKeys.size),
|
|
133
145
|
sessionIds,
|
|
134
146
|
sessionCount: scopedSessions.length,
|
|
135
147
|
startAt: timestamps[0],
|
|
@@ -80,20 +80,28 @@ const RECEIPT_COPY = {
|
|
|
80
80
|
pageTitle: "Codex AI 打工小票",
|
|
81
81
|
receiptTitle: "AI 打工小票",
|
|
82
82
|
themeAria: "小票主题",
|
|
83
|
+
exportImage: "保存完整长图",
|
|
84
|
+
exportingImage: "正在生成…",
|
|
85
|
+
exportSuccess: "图片已保存",
|
|
86
|
+
exportError: "图片生成失败,请重试",
|
|
83
87
|
receiptAria: "Codex AI 打工小票",
|
|
84
88
|
themes: {
|
|
85
|
-
classic: "
|
|
86
|
-
diner: "
|
|
87
|
-
payroll: "
|
|
89
|
+
classic: "经典白票",
|
|
90
|
+
diner: "复古粉票",
|
|
91
|
+
payroll: "夜班绿票",
|
|
88
92
|
},
|
|
89
93
|
scope: {
|
|
90
94
|
latest: "最近一次会话",
|
|
95
|
+
session: "指定会话",
|
|
91
96
|
today: "今日全部会话",
|
|
97
|
+
"last-7-days": "最近 7 个自然日",
|
|
98
|
+
"this-week": "本周全部会话",
|
|
92
99
|
},
|
|
93
100
|
modelMissing: "未记录",
|
|
94
101
|
meta: {
|
|
95
102
|
date: "日期",
|
|
96
103
|
hours: "营业时段",
|
|
104
|
+
period: "统计周期",
|
|
97
105
|
number: "小票编号",
|
|
98
106
|
timezone: "时区",
|
|
99
107
|
},
|
|
@@ -138,20 +146,28 @@ const RECEIPT_COPY = {
|
|
|
138
146
|
pageTitle: "Codex AI Work Receipt",
|
|
139
147
|
receiptTitle: "AI Work Receipt",
|
|
140
148
|
themeAria: "Receipt theme",
|
|
149
|
+
exportImage: "Save full PNG",
|
|
150
|
+
exportingImage: "Rendering…",
|
|
151
|
+
exportSuccess: "Image saved",
|
|
152
|
+
exportError: "Could not generate the image. Please try again.",
|
|
141
153
|
receiptAria: "Codex AI Work Receipt",
|
|
142
154
|
themes: {
|
|
143
|
-
classic: "Classic
|
|
144
|
-
diner: "Vintage
|
|
145
|
-
payroll: "
|
|
155
|
+
classic: "Classic White",
|
|
156
|
+
diner: "Vintage Pink",
|
|
157
|
+
payroll: "Night Shift Green",
|
|
146
158
|
},
|
|
147
159
|
scope: {
|
|
148
160
|
latest: "Latest session",
|
|
161
|
+
session: "Selected session",
|
|
149
162
|
today: "All sessions today",
|
|
163
|
+
"last-7-days": "Last 7 calendar days",
|
|
164
|
+
"this-week": "All sessions this week",
|
|
150
165
|
},
|
|
151
166
|
modelMissing: "Not recorded",
|
|
152
167
|
meta: {
|
|
153
168
|
date: "Date",
|
|
154
169
|
hours: "Work hours",
|
|
170
|
+
period: "Period",
|
|
155
171
|
number: "Receipt No.",
|
|
156
172
|
timezone: "Timezone",
|
|
157
173
|
},
|
|
@@ -196,25 +212,32 @@ const RECEIPT_COPY = {
|
|
|
196
212
|
const COMPENSATION_COPY = {
|
|
197
213
|
"zh-CN": {
|
|
198
214
|
latest: "本单工资",
|
|
215
|
+
session: "本单工资",
|
|
199
216
|
today: "本日工资",
|
|
217
|
+
"last-7-days": "近七日工资",
|
|
218
|
+
"this-week": "本周工资",
|
|
200
219
|
unit: "AI 工分",
|
|
201
220
|
note: "按轮次、工具调用、Token 和改需求次数娱乐折算,不代表真实费用。",
|
|
202
221
|
},
|
|
203
222
|
en: {
|
|
204
223
|
latest: "SHIFT PAY",
|
|
224
|
+
session: "SHIFT PAY",
|
|
205
225
|
today: "TODAY'S PAY",
|
|
226
|
+
"last-7-days": "7-DAY PAY",
|
|
227
|
+
"this-week": "THIS WEEK'S PAY",
|
|
206
228
|
unit: "AI work pts",
|
|
207
229
|
note: "A playful score based on turns, tool calls, Tokens, and interruptions. Not a real charge.",
|
|
208
230
|
},
|
|
209
231
|
};
|
|
210
232
|
|
|
211
233
|
export function selectWorkProfileId(metrics) {
|
|
212
|
-
|
|
213
|
-
if (metrics.
|
|
214
|
-
if (metrics.
|
|
215
|
-
if (metrics.
|
|
216
|
-
if (metrics.
|
|
217
|
-
if (metrics.
|
|
234
|
+
const scale = Math.max(1, Number(metrics.activeDayCount || 1));
|
|
235
|
+
if (metrics.interruptions >= 3 * scale) return "change-request-survivor";
|
|
236
|
+
if (metrics.toolCalls >= 40 * scale) return "toolchain-commander";
|
|
237
|
+
if (metrics.tokens.total_tokens >= 500_000 * scale) return "context-devouring-beast";
|
|
238
|
+
if (metrics.completedTurns >= 12 * scale) return "continuous-delivery-machine";
|
|
239
|
+
if (metrics.workDurationMs >= 60 * 60 * 1000 * scale) return "night-shift-companion";
|
|
240
|
+
if (metrics.completedTurns >= 5 * scale) return "steady-progress-partner";
|
|
218
241
|
return "temporary-hire";
|
|
219
242
|
}
|
|
220
243
|
|
|
@@ -230,7 +253,7 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
|
|
|
230
253
|
export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
|
|
231
254
|
const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
|
|
232
255
|
return {
|
|
233
|
-
label: scope
|
|
256
|
+
label: copy[scope] || copy.latest,
|
|
234
257
|
amount: Number(amount || 0),
|
|
235
258
|
unit: copy.unit,
|
|
236
259
|
note: copy.note,
|
package/src/core/qr-payload.mjs
CHANGED
|
@@ -5,18 +5,26 @@ import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
|
|
|
5
5
|
|
|
6
6
|
export function compactReceipt(record) {
|
|
7
7
|
const profileId = record.presentation.work_profile;
|
|
8
|
+
const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
|
|
8
9
|
const mobileProfile = profileId
|
|
9
10
|
? getWorkProfileCopy(profileId, "zh-CN")
|
|
10
11
|
: { title: record.presentation.work_title, review: record.presentation.review };
|
|
11
12
|
const mobileCompensation = profileId
|
|
12
|
-
? buildCompensation(
|
|
13
|
+
? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
|
|
13
14
|
: record.presentation.compensation;
|
|
14
15
|
|
|
15
16
|
return {
|
|
16
17
|
v: record.schema_version,
|
|
17
18
|
i: record.id,
|
|
18
19
|
g: record.generated_at,
|
|
19
|
-
|
|
20
|
+
o: scope,
|
|
21
|
+
d: [
|
|
22
|
+
record.period.start_at,
|
|
23
|
+
record.period.end_at,
|
|
24
|
+
record.period.timezone,
|
|
25
|
+
record.period.range_start_date || null,
|
|
26
|
+
record.period.range_end_date || null,
|
|
27
|
+
],
|
|
20
28
|
s: [
|
|
21
29
|
record.stats.session_count,
|
|
22
30
|
record.stats.completed_turns,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { dateKey } from "../lib/time.mjs";
|
|
2
|
+
|
|
3
|
+
export const RECEIPT_SCOPES = new Set([
|
|
4
|
+
"latest",
|
|
5
|
+
"session",
|
|
6
|
+
"today",
|
|
7
|
+
"last-7-days",
|
|
8
|
+
"this-week",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const SCOPE_ALIASES = new Map([
|
|
12
|
+
["latest", "latest"],
|
|
13
|
+
["session", "session"],
|
|
14
|
+
["today", "today"],
|
|
15
|
+
["7d", "last-7-days"],
|
|
16
|
+
["last-7-days", "last-7-days"],
|
|
17
|
+
["last7days", "last-7-days"],
|
|
18
|
+
["week", "this-week"],
|
|
19
|
+
["this-week", "this-week"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function normalizeScope(value) {
|
|
23
|
+
return SCOPE_ALIASES.get(String(value || "").trim().toLowerCase()) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shiftDateKey(value, days) {
|
|
27
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
|
|
28
|
+
if (!match) throw new Error(`无效日期:${value}`);
|
|
29
|
+
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
|
30
|
+
date.setUTCDate(date.getUTCDate() + Number(days || 0));
|
|
31
|
+
return date.toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mondayDateKey(value) {
|
|
35
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
36
|
+
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
37
|
+
const daysSinceMonday = weekday === 0 ? 6 : weekday - 1;
|
|
38
|
+
return shiftDateKey(value, -daysSinceMonday);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveRange(scope, timezone, now = new Date(), sessionId = null) {
|
|
42
|
+
const normalizedScope = normalizeScope(scope);
|
|
43
|
+
if (!normalizedScope || !RECEIPT_SCOPES.has(normalizedScope)) {
|
|
44
|
+
throw new Error(`不支持的统计范围:${scope}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const targetDate = dateKey(now, timezone);
|
|
48
|
+
let startDate = null;
|
|
49
|
+
let endDate = null;
|
|
50
|
+
|
|
51
|
+
if (normalizedScope === "today") {
|
|
52
|
+
startDate = targetDate;
|
|
53
|
+
endDate = targetDate;
|
|
54
|
+
} else if (normalizedScope === "last-7-days") {
|
|
55
|
+
startDate = shiftDateKey(targetDate, -6);
|
|
56
|
+
endDate = targetDate;
|
|
57
|
+
} else if (normalizedScope === "this-week") {
|
|
58
|
+
startDate = mondayDateKey(targetDate);
|
|
59
|
+
endDate = targetDate;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
scope: normalizedScope,
|
|
64
|
+
timezone,
|
|
65
|
+
targetDate,
|
|
66
|
+
startDate,
|
|
67
|
+
endDate,
|
|
68
|
+
sessionId: sessionId || null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isCalendarScope(scope) {
|
|
73
|
+
return scope === "today" || scope === "last-7-days" || scope === "this-week";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isDateInRange(date, range) {
|
|
77
|
+
if (!date) return false;
|
|
78
|
+
if (!isCalendarScope(range.scope)) return true;
|
|
79
|
+
const key = dateKey(date, range.timezone);
|
|
80
|
+
return key >= range.startDate && key <= range.endDate;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function calendarDayCount(range) {
|
|
84
|
+
if (!range.startDate || !range.endDate) return 1;
|
|
85
|
+
let count = 1;
|
|
86
|
+
let current = range.startDate;
|
|
87
|
+
while (current < range.endDate) {
|
|
88
|
+
current = shiftDateKey(current, 1);
|
|
89
|
+
count += 1;
|
|
90
|
+
}
|
|
91
|
+
return count;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function safeSlugSegment(value, fallback = "receipt") {
|
|
95
|
+
const normalized = String(value || "")
|
|
96
|
+
.trim()
|
|
97
|
+
.replace(/^cwr_/, "")
|
|
98
|
+
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
|
99
|
+
.replace(/^-+|-+$/g, "")
|
|
100
|
+
.slice(0, 24);
|
|
101
|
+
return normalized || fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function outputSlugForRange(range, receiptId = "") {
|
|
105
|
+
const scope = normalizeScope(range?.scope) || "latest";
|
|
106
|
+
const startDate = range?.startDate || range?.targetDate || "";
|
|
107
|
+
const endDate = range?.endDate || range?.targetDate || startDate;
|
|
108
|
+
|
|
109
|
+
if (scope === "today") return `today-${safeSlugSegment(endDate, "today")}`;
|
|
110
|
+
if (scope === "last-7-days" || scope === "this-week") {
|
|
111
|
+
const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
|
|
112
|
+
return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
|
|
113
|
+
}
|
|
114
|
+
if (scope === "session") {
|
|
115
|
+
return `session-${safeSlugSegment(range?.sessionId || receiptId, "selected")}`;
|
|
116
|
+
}
|
|
117
|
+
return `latest-${safeSlugSegment(receiptId || endDate, "receipt")}`;
|
|
118
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./presentation.mjs";
|
|
11
11
|
|
|
12
12
|
const SCHEMA_VERSION = 1;
|
|
13
|
-
const COLLECTOR_VERSION = "0.
|
|
13
|
+
const COLLECTOR_VERSION = "0.5.0";
|
|
14
14
|
|
|
15
15
|
function fingerprintSessionIds(sessionIds) {
|
|
16
16
|
return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
|
|
@@ -18,9 +18,11 @@ function fingerprintSessionIds(sessionIds) {
|
|
|
18
18
|
|
|
19
19
|
export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE) {
|
|
20
20
|
const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
|
|
21
|
-
const logicalKey = metrics.mode === "latest"
|
|
21
|
+
const logicalKey = metrics.mode === "latest" || metrics.mode === "session"
|
|
22
22
|
? `latest:${sessionFingerprint}`
|
|
23
|
-
:
|
|
23
|
+
: metrics.mode === "this-week"
|
|
24
|
+
? `this-week:${metrics.rangeStartDate}:${metrics.timezone}`
|
|
25
|
+
: `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
|
|
24
26
|
const id = `cwr_${crypto.createHash("sha256").update(logicalKey).digest("hex").slice(0, 16)}`;
|
|
25
27
|
const snapshotHash = crypto.createHash("sha256").update(JSON.stringify({
|
|
26
28
|
start: metrics.startAt.toISOString(),
|
|
@@ -29,6 +31,9 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
29
31
|
tools: metrics.toolCalls,
|
|
30
32
|
turns: metrics.completedTurns,
|
|
31
33
|
interruptions: metrics.interruptions,
|
|
34
|
+
scope: metrics.mode,
|
|
35
|
+
rangeStartDate: metrics.rangeStartDate,
|
|
36
|
+
rangeEndDate: metrics.rangeEndDate,
|
|
32
37
|
})).digest("hex").slice(0, 16);
|
|
33
38
|
const workProfileId = metrics.workProfileId || "temporary-hire";
|
|
34
39
|
const workProfile = getWorkProfileCopy(workProfileId, locale);
|
|
@@ -50,6 +55,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
50
55
|
start_at: metrics.startAt.toISOString(),
|
|
51
56
|
end_at: metrics.endAt.toISOString(),
|
|
52
57
|
timezone: metrics.timezone,
|
|
58
|
+
range_start_date: metrics.rangeStartDate,
|
|
59
|
+
range_end_date: metrics.rangeEndDate,
|
|
53
60
|
},
|
|
54
61
|
stats: {
|
|
55
62
|
session_count: metrics.sessionCount,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
function formatSessionDate(value, timezone, locale) {
|
|
4
|
+
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "zh-CN", {
|
|
5
|
+
timeZone: timezone,
|
|
6
|
+
month: "2-digit",
|
|
7
|
+
day: "2-digit",
|
|
8
|
+
hour: "2-digit",
|
|
9
|
+
minute: "2-digit",
|
|
10
|
+
hour12: false,
|
|
11
|
+
}).format(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sessionLine(session, index, timezone, locale) {
|
|
15
|
+
const start = formatSessionDate(session.startAt, timezone, locale);
|
|
16
|
+
const end = formatSessionDate(session.endAt, timezone, locale);
|
|
17
|
+
const model = session.model || (locale === "en" ? "model unknown" : "模型未记录");
|
|
18
|
+
if (locale === "en") {
|
|
19
|
+
return `${index + 1}. ${start}–${end} · ${session.completedTurns} turns · ${session.toolCalls} tool calls · ${model}`;
|
|
20
|
+
}
|
|
21
|
+
return `${index + 1}. ${start}–${end} · ${session.completedTurns} 轮 · ${session.toolCalls} 次工具调用 · ${model}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function askForNumber(readline, prompt, minimum, maximum, fallback = null) {
|
|
25
|
+
while (true) {
|
|
26
|
+
const answer = (await readline.question(prompt)).trim();
|
|
27
|
+
if (!answer && fallback !== null) return fallback;
|
|
28
|
+
const value = Number(answer);
|
|
29
|
+
if (Number.isInteger(value) && value >= minimum && value <= maximum) return value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function promptForRange({ locale, timezone, loadRecentSessions }) {
|
|
34
|
+
const isEnglish = locale === "en";
|
|
35
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
|
|
39
|
+
console.log(isEnglish ? "1. All activity today (recommended)" : "1. 今天全部活动(推荐)");
|
|
40
|
+
console.log(isEnglish ? "2. Last 7 calendar days" : "2. 最近 7 个自然日");
|
|
41
|
+
console.log(isEnglish ? "3. This week (Monday to now)" : "3. 本周(周一至今)");
|
|
42
|
+
console.log(isEnglish ? "4. Choose a specific session" : "4. 选择一个具体会话");
|
|
43
|
+
|
|
44
|
+
const choice = await askForNumber(
|
|
45
|
+
readline,
|
|
46
|
+
isEnglish ? "\nEnter 1–4 (default 1): " : "\n请输入 1–4(默认 1):",
|
|
47
|
+
1,
|
|
48
|
+
4,
|
|
49
|
+
1,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (choice === 1) return { mode: "today", sessionId: null };
|
|
53
|
+
if (choice === 2) return { mode: "last-7-days", sessionId: null };
|
|
54
|
+
if (choice === 3) return { mode: "this-week", sessionId: null };
|
|
55
|
+
|
|
56
|
+
const sessions = await loadRecentSessions();
|
|
57
|
+
if (!sessions.length) throw new Error(isEnglish ? "No Codex sessions found" : "没有找到可选择的 Codex 会话");
|
|
58
|
+
|
|
59
|
+
console.log(isEnglish ? "\nRecent sessions:\n" : "\n最近的会话:\n");
|
|
60
|
+
sessions.forEach((session, index) => console.log(sessionLine(session, index, timezone, locale)));
|
|
61
|
+
const sessionChoice = await askForNumber(
|
|
62
|
+
readline,
|
|
63
|
+
isEnglish ? `\nEnter 1–${sessions.length}: ` : `\n请输入 1–${sessions.length}:`,
|
|
64
|
+
1,
|
|
65
|
+
sessions.length,
|
|
66
|
+
);
|
|
67
|
+
return { mode: "session", sessionId: sessions[sessionChoice - 1].sessionId };
|
|
68
|
+
} finally {
|
|
69
|
+
readline.close();
|
|
70
|
+
}
|
|
71
|
+
}
|