codex-work-receipt 0.3.0 → 0.4.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 +8 -0
- package/README.en.md +28 -111
- package/README.md +29 -189
- package/docs/cli.en.md +133 -0
- package/docs/cli.md +133 -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 +1 -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 +22 -7
- package/src/core/qr-payload.mjs +10 -2
- package/src/core/range.mjs +99 -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 +14 -2
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],
|
|
@@ -88,12 +88,16 @@ const RECEIPT_COPY = {
|
|
|
88
88
|
},
|
|
89
89
|
scope: {
|
|
90
90
|
latest: "最近一次会话",
|
|
91
|
+
session: "指定会话",
|
|
91
92
|
today: "今日全部会话",
|
|
93
|
+
"last-7-days": "最近 7 个自然日",
|
|
94
|
+
"this-week": "本周全部会话",
|
|
92
95
|
},
|
|
93
96
|
modelMissing: "未记录",
|
|
94
97
|
meta: {
|
|
95
98
|
date: "日期",
|
|
96
99
|
hours: "营业时段",
|
|
100
|
+
period: "统计周期",
|
|
97
101
|
number: "小票编号",
|
|
98
102
|
timezone: "时区",
|
|
99
103
|
},
|
|
@@ -146,12 +150,16 @@ const RECEIPT_COPY = {
|
|
|
146
150
|
},
|
|
147
151
|
scope: {
|
|
148
152
|
latest: "Latest session",
|
|
153
|
+
session: "Selected session",
|
|
149
154
|
today: "All sessions today",
|
|
155
|
+
"last-7-days": "Last 7 calendar days",
|
|
156
|
+
"this-week": "All sessions this week",
|
|
150
157
|
},
|
|
151
158
|
modelMissing: "Not recorded",
|
|
152
159
|
meta: {
|
|
153
160
|
date: "Date",
|
|
154
161
|
hours: "Work hours",
|
|
162
|
+
period: "Period",
|
|
155
163
|
number: "Receipt No.",
|
|
156
164
|
timezone: "Timezone",
|
|
157
165
|
},
|
|
@@ -196,25 +204,32 @@ const RECEIPT_COPY = {
|
|
|
196
204
|
const COMPENSATION_COPY = {
|
|
197
205
|
"zh-CN": {
|
|
198
206
|
latest: "本单工资",
|
|
207
|
+
session: "本单工资",
|
|
199
208
|
today: "本日工资",
|
|
209
|
+
"last-7-days": "近七日工资",
|
|
210
|
+
"this-week": "本周工资",
|
|
200
211
|
unit: "AI 工分",
|
|
201
212
|
note: "按轮次、工具调用、Token 和改需求次数娱乐折算,不代表真实费用。",
|
|
202
213
|
},
|
|
203
214
|
en: {
|
|
204
215
|
latest: "SHIFT PAY",
|
|
216
|
+
session: "SHIFT PAY",
|
|
205
217
|
today: "TODAY'S PAY",
|
|
218
|
+
"last-7-days": "7-DAY PAY",
|
|
219
|
+
"this-week": "THIS WEEK'S PAY",
|
|
206
220
|
unit: "AI work pts",
|
|
207
221
|
note: "A playful score based on turns, tool calls, Tokens, and interruptions. Not a real charge.",
|
|
208
222
|
},
|
|
209
223
|
};
|
|
210
224
|
|
|
211
225
|
export function selectWorkProfileId(metrics) {
|
|
212
|
-
|
|
213
|
-
if (metrics.
|
|
214
|
-
if (metrics.
|
|
215
|
-
if (metrics.
|
|
216
|
-
if (metrics.
|
|
217
|
-
if (metrics.
|
|
226
|
+
const scale = Math.max(1, Number(metrics.activeDayCount || 1));
|
|
227
|
+
if (metrics.interruptions >= 3 * scale) return "change-request-survivor";
|
|
228
|
+
if (metrics.toolCalls >= 40 * scale) return "toolchain-commander";
|
|
229
|
+
if (metrics.tokens.total_tokens >= 500_000 * scale) return "context-devouring-beast";
|
|
230
|
+
if (metrics.completedTurns >= 12 * scale) return "continuous-delivery-machine";
|
|
231
|
+
if (metrics.workDurationMs >= 60 * 60 * 1000 * scale) return "night-shift-companion";
|
|
232
|
+
if (metrics.completedTurns >= 5 * scale) return "steady-progress-partner";
|
|
218
233
|
return "temporary-hire";
|
|
219
234
|
}
|
|
220
235
|
|
|
@@ -230,7 +245,7 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
|
|
|
230
245
|
export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
|
|
231
246
|
const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
|
|
232
247
|
return {
|
|
233
|
-
label: scope
|
|
248
|
+
label: copy[scope] || copy.latest,
|
|
234
249
|
amount: Number(amount || 0),
|
|
235
250
|
unit: copy.unit,
|
|
236
251
|
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,99 @@
|
|
|
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
|
+
export function outputSlugForScope(scope) {
|
|
95
|
+
if (scope === "last-7-days") return "last-7-days";
|
|
96
|
+
if (scope === "this-week") return "this-week";
|
|
97
|
+
if (scope === "session") return "session";
|
|
98
|
+
return scope;
|
|
99
|
+
}
|
|
@@ -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.4.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
|
+
}
|
package/src/parsers/codex.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { rowDate } from "../lib/time.mjs";
|
|
6
|
+
|
|
5
7
|
function walkJsonlFiles(directory, accumulator = []) {
|
|
6
8
|
if (!fs.existsSync(directory)) return accumulator;
|
|
7
9
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
@@ -26,26 +28,90 @@ function readJsonl(filePath) {
|
|
|
26
28
|
return rows;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
function sessionFromFile(file) {
|
|
32
|
+
const rows = readJsonl(file.filePath);
|
|
33
|
+
const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
|
|
34
|
+
const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
|
|
35
|
+
const fallbackDate = new Date(file.modifiedAt);
|
|
36
|
+
return {
|
|
37
|
+
rows,
|
|
38
|
+
filePath: file.filePath,
|
|
39
|
+
modifiedAt: file.modifiedAt,
|
|
40
|
+
sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
|
|
41
|
+
startAt: timestamps[0] || fallbackDate,
|
|
42
|
+
endAt: timestamps.at(-1) || fallbackDate,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function codexSessionFiles() {
|
|
30
47
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
31
48
|
const sessionsDirectory = path.join(codexHome, "sessions");
|
|
32
49
|
const files = walkJsonlFiles(sessionsDirectory)
|
|
33
50
|
.map((filePath) => ({ filePath, modifiedAt: fs.statSync(filePath).mtimeMs }))
|
|
34
51
|
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
35
|
-
|
|
36
52
|
if (!files.length) throw new Error(`没有在 ${sessionsDirectory} 找到 Codex 会话记录`);
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function calendarCandidates(files, startDate) {
|
|
57
|
+
if (!startDate) return files;
|
|
58
|
+
const approximateStart = Date.parse(`${startDate}T00:00:00.000Z`) - 48 * 60 * 60 * 1000;
|
|
59
|
+
return files.filter((file) => file.modifiedAt >= approximateStart);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadCodexSessions(range) {
|
|
63
|
+
const files = codexSessionFiles();
|
|
64
|
+
let candidates = files;
|
|
37
65
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
if (range.scope === "latest") {
|
|
67
|
+
candidates = files.slice(0, 40);
|
|
68
|
+
} else if (range.scope === "session" && range.sessionId) {
|
|
69
|
+
const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
|
|
70
|
+
candidates = filenameMatches.length ? filenameMatches : files;
|
|
71
|
+
} else {
|
|
72
|
+
candidates = calendarCandidates(files, range.startDate);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sessions = candidates
|
|
76
|
+
.map(sessionFromFile)
|
|
77
|
+
.sort((left, right) => right.endAt - left.endAt);
|
|
78
|
+
|
|
79
|
+
if (range.scope === "latest") return sessions.slice(0, 1);
|
|
80
|
+
if (range.scope === "session") {
|
|
81
|
+
const selected = sessions.find((session) => session.sessionId === range.sessionId);
|
|
82
|
+
if (!selected) throw new Error(`没有找到指定的 Codex 会话:${range.sessionId}`);
|
|
83
|
+
return [selected];
|
|
84
|
+
}
|
|
85
|
+
return sessions;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listRecentCodexSessions(limit = 10) {
|
|
89
|
+
const sessions = codexSessionFiles()
|
|
90
|
+
.slice(0, Math.max(40, limit * 3))
|
|
91
|
+
.map(sessionFromFile)
|
|
92
|
+
.filter((session) => session.rows.length)
|
|
93
|
+
.sort((left, right) => right.endAt - left.endAt)
|
|
94
|
+
.slice(0, limit);
|
|
41
95
|
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
96
|
+
return sessions.map((session) => {
|
|
97
|
+
let completedTurns = 0;
|
|
98
|
+
let toolCalls = 0;
|
|
99
|
+
let model = null;
|
|
100
|
+
for (const row of session.rows) {
|
|
101
|
+
if (row.type === "turn_context" && row.payload?.model) model = row.payload.model;
|
|
102
|
+
if (row.type === "event_msg" && row.payload?.type === "task_complete") completedTurns += 1;
|
|
103
|
+
if (
|
|
104
|
+
row.type === "response_item" &&
|
|
105
|
+
(row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
|
|
106
|
+
) toolCalls += 1;
|
|
107
|
+
}
|
|
45
108
|
return {
|
|
46
|
-
|
|
47
|
-
|
|
109
|
+
sessionId: session.sessionId,
|
|
110
|
+
startAt: session.startAt,
|
|
111
|
+
endAt: session.endAt,
|
|
112
|
+
completedTurns,
|
|
113
|
+
toolCalls,
|
|
114
|
+
model,
|
|
48
115
|
};
|
|
49
116
|
});
|
|
50
117
|
}
|
|
51
|
-
|