codex-work-receipt 0.8.1 → 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 +19 -0
- package/README.en.md +14 -4
- package/README.md +14 -4
- 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 +20 -4
- package/docs/data-schema.md +20 -4
- package/docs/privacy.en.md +8 -3
- package/docs/privacy.md +8 -3
- 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 +143 -15
- package/src/core/presentation.mjs +190 -3
- package/src/core/project-identity.mjs +132 -0
- package/src/core/range.mjs +155 -8
- package/src/core/receipt-record.mjs +36 -1
- package/src/core/selector.mjs +146 -21
- package/src/lib/tool-category.mjs +36 -0
- package/src/parsers/codex.mjs +58 -7
- package/src/renderers/html.mjs +661 -2
package/src/cli.mjs
CHANGED
|
@@ -18,13 +18,19 @@ import {
|
|
|
18
18
|
import { generateReceipt } from "./core/generator.mjs";
|
|
19
19
|
import { promptForGenerationMode } from "./core/mode-selector.mjs";
|
|
20
20
|
import { printOpenSourcePrompt } from "./core/open-source.mjs";
|
|
21
|
-
import { getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
|
|
21
|
+
import { getCustomSummaryNotice, getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
|
|
22
|
+
import { getProjectIdentitySecret, projectDescriptorFromPath } from "./core/project-identity.mjs";
|
|
22
23
|
import { encodeSingleReceiptQr } from "./core/qr-payload.mjs";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
promptForCustomRange,
|
|
26
|
+
promptForProjectRange,
|
|
27
|
+
promptForRange,
|
|
28
|
+
promptForSpecificSession,
|
|
29
|
+
} from "./core/selector.mjs";
|
|
24
30
|
import { installCodexSkill } from "./core/skill-installer.mjs";
|
|
25
31
|
import { installCodexPet, uninstallCodexPet } from "./core/pet-installer.mjs";
|
|
26
32
|
import { formatNumber } from "./lib/time.mjs";
|
|
27
|
-
import { listRecentCodexSessions } from "./parsers/codex.mjs";
|
|
33
|
+
import { listRecentCodexProjects, listRecentCodexSessions } from "./parsers/codex.mjs";
|
|
28
34
|
|
|
29
35
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
30
36
|
const PROJECT_DIR = path.dirname(SCRIPT_DIR);
|
|
@@ -185,6 +191,49 @@ async function main() {
|
|
|
185
191
|
return;
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
if ((options.selectSession || options.selectProject || (options.mode === "custom-range" && !options.from)) && !isInteractive()) {
|
|
195
|
+
throw new Error("交互选择命令需要在终端中运行;也可以使用 --session、--project、--from 和 --to");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let projectSecret = null;
|
|
199
|
+
const ensureProjectSecret = () => {
|
|
200
|
+
projectSecret ||= getProjectIdentitySecret({ dataDir: options.dataDir });
|
|
201
|
+
return projectSecret;
|
|
202
|
+
};
|
|
203
|
+
const loadRecentProjects = () => listRecentCodexProjects(10, {
|
|
204
|
+
projectSecret: ensureProjectSecret(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (options.project) {
|
|
208
|
+
options.projectId = projectDescriptorFromPath(options.project, ensureProjectSecret()).projectId;
|
|
209
|
+
}
|
|
210
|
+
if (options.selectSession) {
|
|
211
|
+
const selected = await promptForSpecificSession({
|
|
212
|
+
locale: options.locale,
|
|
213
|
+
timezone: options.timezone,
|
|
214
|
+
loadRecentSessions: () => listRecentCodexSessions(10),
|
|
215
|
+
});
|
|
216
|
+
options.mode = selected.mode;
|
|
217
|
+
options.sessionId = selected.sessionId;
|
|
218
|
+
options.modeExplicit = true;
|
|
219
|
+
} else if (options.selectProject) {
|
|
220
|
+
const selected = await promptForProjectRange({
|
|
221
|
+
locale: options.locale,
|
|
222
|
+
timezone: options.timezone,
|
|
223
|
+
loadRecentProjects,
|
|
224
|
+
});
|
|
225
|
+
options.mode = selected.mode;
|
|
226
|
+
options.projectId = selected.projectId;
|
|
227
|
+
options.hours = selected.hours || options.hours;
|
|
228
|
+
options.from = selected.from || options.from;
|
|
229
|
+
options.to = selected.to || options.to;
|
|
230
|
+
options.modeExplicit = true;
|
|
231
|
+
} else if (options.mode === "custom-range" && !options.from) {
|
|
232
|
+
const selected = await promptForCustomRange({ locale: options.locale, timezone: options.timezone });
|
|
233
|
+
options.from = selected.from;
|
|
234
|
+
options.to = selected.to;
|
|
235
|
+
}
|
|
236
|
+
|
|
188
237
|
if (!options.modeExplicit && isInteractive()) {
|
|
189
238
|
const workReceiptHome = getWorkReceiptHome({ dataDir: options.dataDir });
|
|
190
239
|
if (!readAutoConfig({ workReceiptHome })) {
|
|
@@ -199,10 +248,14 @@ async function main() {
|
|
|
199
248
|
locale: options.locale,
|
|
200
249
|
timezone: options.timezone,
|
|
201
250
|
loadRecentSessions: () => listRecentCodexSessions(10),
|
|
251
|
+
loadRecentProjects,
|
|
202
252
|
});
|
|
203
253
|
options.mode = selected.mode;
|
|
204
254
|
options.sessionId = selected.sessionId;
|
|
205
255
|
options.hours = selected.hours || options.hours;
|
|
256
|
+
options.projectId = selected.projectId || options.projectId;
|
|
257
|
+
options.from = selected.from || options.from;
|
|
258
|
+
options.to = selected.to || options.to;
|
|
206
259
|
}
|
|
207
260
|
|
|
208
261
|
const generated = await generateReceipt(options, {
|
|
@@ -235,7 +288,7 @@ async function main() {
|
|
|
235
288
|
console.log(`Structured data: ${persisted.companionPath}`);
|
|
236
289
|
console.log(`WeChat import file: ${persisted.transferPath}`);
|
|
237
290
|
console.log(`Local history: ${persisted.receiptPath}`);
|
|
238
|
-
console.log(`Range: ${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} session(s)`);
|
|
291
|
+
console.log(`Range: ${getScopeLabel(record.source.scope, options.locale, record.source.hours, { rangeKind: record.source.range_kind, filterKind: record.source.filter_kind })} · ${record.stats.session_count} session(s)`);
|
|
239
292
|
console.log(`Stats: ${record.stats.completed_turns} turns · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Tokens · ${record.stats.tool_calls} tool calls`);
|
|
240
293
|
console.log(dataQrDataUrl
|
|
241
294
|
? `Data QR: available as one code · QR version ${dataQrVersion}`
|
|
@@ -246,13 +299,16 @@ async function main() {
|
|
|
246
299
|
if (record.source.scope === "last-hours") {
|
|
247
300
|
console.log(`Note: ${getRollingSummaryNotice(options.locale, record.source.hours)}`);
|
|
248
301
|
}
|
|
302
|
+
if (record.source.scope === "custom-range" && record.source.range_kind === "exact-time") {
|
|
303
|
+
console.log(`Note: ${getCustomSummaryNotice(options.locale)}`);
|
|
304
|
+
}
|
|
249
305
|
if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
|
|
250
306
|
} else {
|
|
251
307
|
console.log(`已生成网页:${outputFile}`);
|
|
252
308
|
console.log(`结构数据:${persisted.companionPath}`);
|
|
253
309
|
console.log(`微信导入文件:${persisted.transferPath}`);
|
|
254
310
|
console.log(`本地历史:${persisted.receiptPath}`);
|
|
255
|
-
console.log(`统计范围:${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} 个会话`);
|
|
311
|
+
console.log(`统计范围:${getScopeLabel(record.source.scope, options.locale, record.source.hours, { rangeKind: record.source.range_kind, filterKind: record.source.filter_kind })} · ${record.stats.session_count} 个会话`);
|
|
256
312
|
console.log(`统计:${record.stats.completed_turns} 轮 · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Token · ${record.stats.tool_calls} 次工具调用`);
|
|
257
313
|
console.log(dataQrDataUrl
|
|
258
314
|
? `数据二维码:可用 · 单码 · QR version ${dataQrVersion}`
|
|
@@ -263,6 +319,9 @@ async function main() {
|
|
|
263
319
|
if (record.source.scope === "last-hours") {
|
|
264
320
|
console.log(`提示:${getRollingSummaryNotice(options.locale, record.source.hours)}`);
|
|
265
321
|
}
|
|
322
|
+
if (record.source.scope === "custom-range" && record.source.range_kind === "exact-time") {
|
|
323
|
+
console.log(`提示:${getCustomSummaryNotice(options.locale)}`);
|
|
324
|
+
}
|
|
266
325
|
if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
|
|
267
326
|
}
|
|
268
327
|
printOpenSourcePrompt("receipt", options.locale);
|
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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { dateKey, rowDate } from "../lib/time.mjs";
|
|
2
|
+
import { toolCategoryForRow } from "../lib/tool-category.mjs";
|
|
2
3
|
import {
|
|
3
4
|
calendarDayCount,
|
|
4
|
-
|
|
5
|
+
isCalendarRange,
|
|
5
6
|
isDateInRange,
|
|
6
|
-
|
|
7
|
+
isTimeWindowRange,
|
|
7
8
|
} from "./range.mjs";
|
|
8
9
|
import { selectWorkProfileId } from "./presentation.mjs";
|
|
9
10
|
|
|
@@ -21,6 +22,83 @@ function addUsage(target, source) {
|
|
|
21
22
|
for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function increment(map, key, amount = 1) {
|
|
26
|
+
if (!key) return;
|
|
27
|
+
map.set(key, Number(map.get(key) || 0) + amount);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function roundedRatio(numerator, denominator, digits = 2) {
|
|
31
|
+
if (!denominator) return 0;
|
|
32
|
+
const scale = 10 ** digits;
|
|
33
|
+
return Math.round((Number(numerator || 0) / denominator) * scale) / scale;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function percentile(samples, quantile) {
|
|
37
|
+
if (!samples.length) return 0;
|
|
38
|
+
const sorted = [...samples].sort((left, right) => left - right);
|
|
39
|
+
const position = (sorted.length - 1) * quantile;
|
|
40
|
+
const lowerIndex = Math.floor(position);
|
|
41
|
+
const upperIndex = Math.ceil(position);
|
|
42
|
+
const lower = sorted[lowerIndex];
|
|
43
|
+
const upper = sorted[upperIndex];
|
|
44
|
+
return Math.round(lower + (upper - lower) * (position - lowerIndex));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function usageRows(map, keyName) {
|
|
48
|
+
return [...map.entries()]
|
|
49
|
+
.map(([name, count]) => ({ [keyName]: name, count }))
|
|
50
|
+
.sort((left, right) => right.count - left.count || left[keyName].localeCompare(right[keyName]));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function localHour(date, timezone) {
|
|
54
|
+
if (!date) return null;
|
|
55
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
56
|
+
timeZone: timezone,
|
|
57
|
+
hour: "2-digit",
|
|
58
|
+
hourCycle: "h23",
|
|
59
|
+
}).format(date));
|
|
60
|
+
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildInsights({
|
|
64
|
+
tokens,
|
|
65
|
+
completedTurns,
|
|
66
|
+
toolCalls,
|
|
67
|
+
completedDurationMs,
|
|
68
|
+
firstTokenSamples,
|
|
69
|
+
turnDurationSamples,
|
|
70
|
+
activityByHour,
|
|
71
|
+
modelUsage,
|
|
72
|
+
toolUsage,
|
|
73
|
+
}) {
|
|
74
|
+
const inputTokens = Math.max(0, Number(tokens.input_tokens || 0));
|
|
75
|
+
const cachedInputTokens = Math.max(0, Number(tokens.cached_input_tokens || 0));
|
|
76
|
+
return {
|
|
77
|
+
cache_hit_rate: inputTokens ? Math.min(1, roundedRatio(cachedInputTokens, inputTokens, 4)) : 0,
|
|
78
|
+
per_turn: {
|
|
79
|
+
total_tokens: roundedRatio(tokens.total_tokens, completedTurns),
|
|
80
|
+
output_tokens: roundedRatio(tokens.output_tokens, completedTurns),
|
|
81
|
+
tool_calls: roundedRatio(toolCalls, completedTurns),
|
|
82
|
+
work_duration_ms: Math.round(roundedRatio(completedDurationMs, completedTurns)),
|
|
83
|
+
},
|
|
84
|
+
latency_ms: {
|
|
85
|
+
first_token: {
|
|
86
|
+
sample_count: firstTokenSamples.length,
|
|
87
|
+
p50: percentile(firstTokenSamples, 0.5),
|
|
88
|
+
p90: percentile(firstTokenSamples, 0.9),
|
|
89
|
+
},
|
|
90
|
+
turn: {
|
|
91
|
+
sample_count: turnDurationSamples.length,
|
|
92
|
+
p50: percentile(turnDurationSamples, 0.5),
|
|
93
|
+
p90: percentile(turnDurationSamples, 0.9),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
activity_by_hour: [...activityByHour],
|
|
97
|
+
model_usage: usageRows(modelUsage, "model"),
|
|
98
|
+
tool_usage: usageRows(toolUsage, "category"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
24
102
|
function sessionTokenUsage(rows, range) {
|
|
25
103
|
const events = rows
|
|
26
104
|
.filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
|
|
@@ -33,7 +111,7 @@ function sessionTokenUsage(rows, range) {
|
|
|
33
111
|
for (const row of events) {
|
|
34
112
|
const currentUsage = row.payload.info.total_token_usage;
|
|
35
113
|
const date = rowDate(row);
|
|
36
|
-
const selected = (!
|
|
114
|
+
const selected = (!isCalendarRange(range) && !isTimeWindowRange(range))
|
|
37
115
|
|| isDateInRange(date, range);
|
|
38
116
|
|
|
39
117
|
for (const key of Object.keys(totals)) {
|
|
@@ -63,6 +141,7 @@ function calculateWorkPoints(metrics) {
|
|
|
63
141
|
|
|
64
142
|
function emptyRangeMessage(range) {
|
|
65
143
|
if (range.scope === "last-hours") return `最近 ${range.hours} 小时没有找到 Codex 活动`;
|
|
144
|
+
if (range.scope === "custom-range") return "自定义时间区间内没有找到 Codex 活动";
|
|
66
145
|
if (range.scope === "today") return `没有找到 ${range.targetDate} 的 Codex 活动`;
|
|
67
146
|
if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
|
|
68
147
|
if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
|
|
@@ -78,14 +157,20 @@ export function collectMetrics(sessions, range) {
|
|
|
78
157
|
let toolCalls = 0;
|
|
79
158
|
let interruptions = 0;
|
|
80
159
|
let workDurationMs = 0;
|
|
160
|
+
let completedDurationMs = 0;
|
|
81
161
|
let totalFirstTokenMs = 0;
|
|
82
162
|
let firstTokenSamples = 0;
|
|
163
|
+
const firstTokenLatencySamples = [];
|
|
164
|
+
const turnDurationSamples = [];
|
|
83
165
|
const timestamps = [];
|
|
84
166
|
const activeDateKeys = new Set();
|
|
85
167
|
const models = new Set();
|
|
168
|
+
const modelUsage = new Map();
|
|
169
|
+
const toolUsage = new Map();
|
|
170
|
+
const activityByHour = Array(24).fill(0);
|
|
86
171
|
|
|
87
172
|
for (const session of sessions) {
|
|
88
|
-
const scopedRows =
|
|
173
|
+
const scopedRows = isCalendarRange(range) || isTimeWindowRange(range)
|
|
89
174
|
? session.rows.filter((row) => isDateInRange(rowDate(row), range))
|
|
90
175
|
: session.rows;
|
|
91
176
|
|
|
@@ -94,40 +179,70 @@ export function collectMetrics(sessions, range) {
|
|
|
94
179
|
sessionIds.push(session.sessionId);
|
|
95
180
|
addUsage(tokens, sessionTokenUsage(session.rows, range));
|
|
96
181
|
|
|
97
|
-
|
|
182
|
+
let activeModel = null;
|
|
183
|
+
let unattributedModelTurns = 0;
|
|
184
|
+
let sessionHasSelectedModel = false;
|
|
185
|
+
for (const row of session.rows) {
|
|
186
|
+
if (row.type === "turn_context" && row.payload?.model) activeModel = row.payload.model;
|
|
187
|
+
const selected = (!isCalendarRange(range) && !isTimeWindowRange(range))
|
|
188
|
+
|| isDateInRange(rowDate(row), range);
|
|
189
|
+
if (!selected) continue;
|
|
98
190
|
const date = rowDate(row);
|
|
99
191
|
if (date) {
|
|
100
192
|
timestamps.push(date);
|
|
101
193
|
activeDateKeys.add(dateKey(date, range.timezone));
|
|
102
194
|
}
|
|
103
195
|
|
|
104
|
-
if (row.type === "turn_context" && row.payload?.model)
|
|
196
|
+
if (row.type === "turn_context" && row.payload?.model) {
|
|
197
|
+
models.add(row.payload.model);
|
|
198
|
+
sessionHasSelectedModel = true;
|
|
199
|
+
}
|
|
105
200
|
if (row.type === "event_msg") {
|
|
106
201
|
const eventType = row.payload?.type;
|
|
107
202
|
if (eventType === "task_complete") {
|
|
203
|
+
const hasDuration = Number.isFinite(row.payload.duration_ms);
|
|
204
|
+
const durationMs = hasDuration ? Math.max(0, Number(row.payload.duration_ms)) : 0;
|
|
108
205
|
completedTurns += 1;
|
|
109
|
-
workDurationMs +=
|
|
206
|
+
workDurationMs += durationMs;
|
|
207
|
+
completedDurationMs += durationMs;
|
|
208
|
+
if (hasDuration) turnDurationSamples.push(durationMs);
|
|
209
|
+
if (activeModel) {
|
|
210
|
+
models.add(activeModel);
|
|
211
|
+
sessionHasSelectedModel = true;
|
|
212
|
+
increment(modelUsage, activeModel);
|
|
213
|
+
}
|
|
214
|
+
else unattributedModelTurns += 1;
|
|
215
|
+
const hour = localHour(date, range.timezone);
|
|
216
|
+
if (hour !== null) activityByHour[hour] += 1;
|
|
110
217
|
if (Number.isFinite(row.payload.time_to_first_token_ms)) {
|
|
111
|
-
|
|
218
|
+
const firstTokenMs = Math.max(0, Number(row.payload.time_to_first_token_ms));
|
|
219
|
+
totalFirstTokenMs += firstTokenMs;
|
|
112
220
|
firstTokenSamples += 1;
|
|
221
|
+
firstTokenLatencySamples.push(firstTokenMs);
|
|
113
222
|
}
|
|
114
223
|
} else if (eventType === "user_message") userMessages += 1;
|
|
115
224
|
else if (eventType === "turn_aborted") {
|
|
116
225
|
interruptions += 1;
|
|
117
|
-
workDurationMs += Number(row.payload.duration_ms || 0);
|
|
226
|
+
workDurationMs += Math.max(0, Number(row.payload.duration_ms || 0));
|
|
227
|
+
const hour = localHour(date, range.timezone);
|
|
228
|
+
if (hour !== null) activityByHour[hour] += 1;
|
|
118
229
|
}
|
|
119
230
|
}
|
|
120
231
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
232
|
+
const toolCategory = toolCategoryForRow(row);
|
|
233
|
+
if (toolCategory) {
|
|
234
|
+
toolCalls += 1;
|
|
235
|
+
increment(toolUsage, toolCategory);
|
|
236
|
+
}
|
|
125
237
|
}
|
|
126
238
|
|
|
127
239
|
const fallbackModel = [...session.rows]
|
|
128
240
|
.reverse()
|
|
129
241
|
.find((row) => row.type === "turn_context" && row.payload?.model)?.payload?.model;
|
|
130
|
-
if (fallbackModel
|
|
242
|
+
if (fallbackModel && (!sessionHasSelectedModel || unattributedModelTurns)) {
|
|
243
|
+
models.add(fallbackModel);
|
|
244
|
+
if (unattributedModelTurns) increment(modelUsage, fallbackModel, unattributedModelTurns);
|
|
245
|
+
}
|
|
131
246
|
}
|
|
132
247
|
|
|
133
248
|
if (!scopedSessions.length || !timestamps.length) throw new Error(emptyRangeMessage(range));
|
|
@@ -144,7 +259,9 @@ export function collectMetrics(sessions, range) {
|
|
|
144
259
|
windowStartAt: range.startAt,
|
|
145
260
|
windowEndAt: range.endAt,
|
|
146
261
|
windowHours: range.hours,
|
|
147
|
-
|
|
262
|
+
boundaryKind: range.boundaryKind,
|
|
263
|
+
projectId: range.projectId,
|
|
264
|
+
calendarDayCount: isCalendarRange(range) ? calendarDayCount(range) : 1,
|
|
148
265
|
activeDayCount: Math.max(1, activeDateKeys.size),
|
|
149
266
|
sessionIds,
|
|
150
267
|
sessionCount: scopedSessions.length,
|
|
@@ -159,6 +276,17 @@ export function collectMetrics(sessions, range) {
|
|
|
159
276
|
tokens,
|
|
160
277
|
models: [...models],
|
|
161
278
|
};
|
|
279
|
+
metrics.insights = buildInsights({
|
|
280
|
+
tokens,
|
|
281
|
+
completedTurns,
|
|
282
|
+
toolCalls,
|
|
283
|
+
completedDurationMs,
|
|
284
|
+
firstTokenSamples: firstTokenLatencySamples,
|
|
285
|
+
turnDurationSamples,
|
|
286
|
+
activityByHour,
|
|
287
|
+
modelUsage,
|
|
288
|
+
toolUsage,
|
|
289
|
+
});
|
|
162
290
|
metrics.workProfileId = selectWorkProfileId(metrics);
|
|
163
291
|
metrics.workPoints = calculateWorkPoints(metrics);
|
|
164
292
|
return metrics;
|