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
|
@@ -93,6 +93,58 @@ const RECEIPT_COPY = {
|
|
|
93
93
|
changelogLink: "查看更新日志 →",
|
|
94
94
|
sponsorLabel: "赞助伙伴",
|
|
95
95
|
sponsorAlt: "ModelFlare 标志",
|
|
96
|
+
features: {
|
|
97
|
+
title: "更多小票功能",
|
|
98
|
+
countLabel: "{count} 项",
|
|
99
|
+
description: "复制命令后,在终端中运行即可。",
|
|
100
|
+
copyLabel: "复制",
|
|
101
|
+
copiedLabel: "已复制",
|
|
102
|
+
copyErrorLabel: "复制失败",
|
|
103
|
+
copiedStatus: "命令已复制到剪贴板",
|
|
104
|
+
copyErrorStatus: "无法自动复制,请手动选择命令",
|
|
105
|
+
tabAria: "小票功能分类",
|
|
106
|
+
groups: [
|
|
107
|
+
{
|
|
108
|
+
id: "time",
|
|
109
|
+
title: "时间范围",
|
|
110
|
+
commands: [
|
|
111
|
+
{ label: "生成最近一次小票", args: "--latest" },
|
|
112
|
+
{ label: "生成最近 3 小时小票", args: "--hours 3" },
|
|
113
|
+
{ label: "生成今日小票", args: "--today" },
|
|
114
|
+
{ label: "生成最近 7 天小票", args: "--range last-7-days" },
|
|
115
|
+
{ label: "生成本周小票", args: "--range this-week" },
|
|
116
|
+
{ label: "自定义时间区间", args: "--custom-range" },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "selection",
|
|
121
|
+
title: "会话与项目",
|
|
122
|
+
commands: [
|
|
123
|
+
{ label: "选择指定会话", args: "--select-session" },
|
|
124
|
+
{ label: "选择指定项目", args: "--select-project" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "automation",
|
|
129
|
+
title: "自动与手动",
|
|
130
|
+
commands: [
|
|
131
|
+
{ label: "重新选择工作模式", args: "--setup" },
|
|
132
|
+
{ label: "开启自动保存", args: "--enable-auto" },
|
|
133
|
+
{ label: "切换为仅手动", args: "--disable-auto" },
|
|
134
|
+
{ label: "查看自动保存状态", args: "--auto-status" },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "companion",
|
|
139
|
+
title: "票仔与对话开票",
|
|
140
|
+
commands: [
|
|
141
|
+
{ label: "安装票仔和对话开票", args: "--install-companion" },
|
|
142
|
+
{ label: "仅安装票仔", args: "--install-pet" },
|
|
143
|
+
{ label: "仅安装对话开票", args: "--install-skill" },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
96
148
|
},
|
|
97
149
|
themes: {
|
|
98
150
|
classic: "经典白票",
|
|
@@ -103,6 +155,9 @@ const RECEIPT_COPY = {
|
|
|
103
155
|
latest: "最近一次会话",
|
|
104
156
|
session: "指定会话",
|
|
105
157
|
"last-hours": "最近 {hours} 小时",
|
|
158
|
+
"custom-range-calendar-days": "自定义日期",
|
|
159
|
+
"custom-range-exact-time": "自定义时间",
|
|
160
|
+
"custom-range": "自定义区间",
|
|
106
161
|
today: "今日全部会话",
|
|
107
162
|
"last-7-days": "最近 7 个自然日",
|
|
108
163
|
"this-week": "本周全部会话",
|
|
@@ -135,6 +190,36 @@ const RECEIPT_COPY = {
|
|
|
135
190
|
interruptions: ["次", "次"],
|
|
136
191
|
seconds: ["秒", "秒"],
|
|
137
192
|
},
|
|
193
|
+
insights: {
|
|
194
|
+
title: "效率与工作结构",
|
|
195
|
+
cacheHit: "缓存命中率",
|
|
196
|
+
perTurn: "每轮效率",
|
|
197
|
+
totalTokens: "总 Token",
|
|
198
|
+
outputTokens: "输出 Token",
|
|
199
|
+
toolCalls: "工具调用",
|
|
200
|
+
firstTokenLatency: "首次响应延迟",
|
|
201
|
+
turnLatency: "整轮耗时",
|
|
202
|
+
p50: "P50",
|
|
203
|
+
p90: "P90",
|
|
204
|
+
noSamples: "暂无样本",
|
|
205
|
+
heatmap: "工作时间热力图",
|
|
206
|
+
heatmapCell: "{hour}:00 · {count} 轮",
|
|
207
|
+
models: "模型结构",
|
|
208
|
+
tools: "工具结构",
|
|
209
|
+
turnsUnit: "轮",
|
|
210
|
+
callsUnit: "次",
|
|
211
|
+
toolCategories: {
|
|
212
|
+
terminal: "终端命令",
|
|
213
|
+
"file-edit": "文件编辑",
|
|
214
|
+
browser: "浏览器操作",
|
|
215
|
+
research: "检索研究",
|
|
216
|
+
media: "图像与媒体",
|
|
217
|
+
agents: "多代理协作",
|
|
218
|
+
planning: "规划与确认",
|
|
219
|
+
integrations: "外部集成",
|
|
220
|
+
other: "其他工具",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
138
223
|
footerThanks: "谢谢惠顾,欢迎明天继续改需求",
|
|
139
224
|
transferAria: "微信小程序导入联",
|
|
140
225
|
transferTitle: "导入到微信小程序",
|
|
@@ -166,6 +251,8 @@ const RECEIPT_COPY = {
|
|
|
166
251
|
placeholderAria: "小程序码待接入",
|
|
167
252
|
transferNote: "导入文件和可选数据码只包含时间、轮次、Token 和工具调用等统计,不包含 Prompt、回复正文、代码、项目路径或文件名。",
|
|
168
253
|
rollingSummaryNotice: "最近 {hours} 小时属于滚动摘要,只保存到私人历史,不参与 AI 供销社统计。需要统计时请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。",
|
|
254
|
+
customSummaryNotice: "精确时间区间属于私人摘要,不参与 AI 供销社统计。按自然日选择自定义区间时可以生成可去重的规范事实。",
|
|
255
|
+
projectScopeTemplate: "指定项目 · {scope}",
|
|
169
256
|
privacy: "结构数据和微信导入文件同时保存在本机;只有你主动发送文件或扫码时,脱敏统计才会离开电脑。",
|
|
170
257
|
},
|
|
171
258
|
en: {
|
|
@@ -186,6 +273,58 @@ const RECEIPT_COPY = {
|
|
|
186
273
|
changelogLink: "View the changelog →",
|
|
187
274
|
sponsorLabel: "Sponsored by",
|
|
188
275
|
sponsorAlt: "ModelFlare logo",
|
|
276
|
+
features: {
|
|
277
|
+
title: "More receipt features",
|
|
278
|
+
countLabel: "{count} commands",
|
|
279
|
+
description: "Copy a command, then run it in your terminal.",
|
|
280
|
+
copyLabel: "Copy",
|
|
281
|
+
copiedLabel: "Copied",
|
|
282
|
+
copyErrorLabel: "Copy failed",
|
|
283
|
+
copiedStatus: "Command copied to the clipboard",
|
|
284
|
+
copyErrorStatus: "Could not copy automatically. Select the command manually.",
|
|
285
|
+
tabAria: "Receipt feature categories",
|
|
286
|
+
groups: [
|
|
287
|
+
{
|
|
288
|
+
id: "time",
|
|
289
|
+
title: "Time ranges",
|
|
290
|
+
commands: [
|
|
291
|
+
{ label: "Generate the latest receipt", args: "--latest" },
|
|
292
|
+
{ label: "Generate the last 3 hours", args: "--hours 3" },
|
|
293
|
+
{ label: "Generate today's receipt", args: "--today" },
|
|
294
|
+
{ label: "Generate the last 7 days", args: "--range last-7-days" },
|
|
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" },
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "automation",
|
|
309
|
+
title: "Automatic and manual",
|
|
310
|
+
commands: [
|
|
311
|
+
{ label: "Choose a working mode", args: "--setup" },
|
|
312
|
+
{ label: "Enable automatic saving", args: "--enable-auto" },
|
|
313
|
+
{ label: "Switch to manual only", args: "--disable-auto" },
|
|
314
|
+
{ label: "Check automatic saving", args: "--auto-status" },
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: "companion",
|
|
319
|
+
title: "Ticket Buddy and chat commands",
|
|
320
|
+
commands: [
|
|
321
|
+
{ label: "Install Ticket Buddy and chat commands", args: "--install-companion" },
|
|
322
|
+
{ label: "Install Ticket Buddy only", args: "--install-pet" },
|
|
323
|
+
{ label: "Install chat commands only", args: "--install-skill" },
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
},
|
|
189
328
|
},
|
|
190
329
|
themes: {
|
|
191
330
|
classic: "Classic White",
|
|
@@ -196,6 +335,9 @@ const RECEIPT_COPY = {
|
|
|
196
335
|
latest: "Latest session",
|
|
197
336
|
session: "Selected session",
|
|
198
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",
|
|
199
341
|
today: "All sessions today",
|
|
200
342
|
"last-7-days": "Last 7 calendar days",
|
|
201
343
|
"this-week": "All sessions this week",
|
|
@@ -228,6 +370,36 @@ const RECEIPT_COPY = {
|
|
|
228
370
|
interruptions: ["time", "times"],
|
|
229
371
|
seconds: ["sec", "sec"],
|
|
230
372
|
},
|
|
373
|
+
insights: {
|
|
374
|
+
title: "Efficiency & work structure",
|
|
375
|
+
cacheHit: "Cache hit rate",
|
|
376
|
+
perTurn: "Per-turn efficiency",
|
|
377
|
+
totalTokens: "total Tokens",
|
|
378
|
+
outputTokens: "output Tokens",
|
|
379
|
+
toolCalls: "tool calls",
|
|
380
|
+
firstTokenLatency: "First-token latency",
|
|
381
|
+
turnLatency: "Full-turn duration",
|
|
382
|
+
p50: "P50",
|
|
383
|
+
p90: "P90",
|
|
384
|
+
noSamples: "No samples",
|
|
385
|
+
heatmap: "Work-time heatmap",
|
|
386
|
+
heatmapCell: "{hour}:00 · {count} turns",
|
|
387
|
+
models: "Model structure",
|
|
388
|
+
tools: "Tool structure",
|
|
389
|
+
turnsUnit: "turns",
|
|
390
|
+
callsUnit: "calls",
|
|
391
|
+
toolCategories: {
|
|
392
|
+
terminal: "Terminal",
|
|
393
|
+
"file-edit": "File editing",
|
|
394
|
+
browser: "Browser",
|
|
395
|
+
research: "Research",
|
|
396
|
+
media: "Image & media",
|
|
397
|
+
agents: "Multi-agent",
|
|
398
|
+
planning: "Planning",
|
|
399
|
+
integrations: "Integrations",
|
|
400
|
+
other: "Other tools",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
231
403
|
footerThanks: "Thanks for your business. More revisions welcome tomorrow.",
|
|
232
404
|
transferAria: "WeChat mini-program import stub",
|
|
233
405
|
transferTitle: "IMPORT INTO WECHAT",
|
|
@@ -259,6 +431,8 @@ const RECEIPT_COPY = {
|
|
|
259
431
|
placeholderAria: "Mini-program code pending",
|
|
260
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.",
|
|
261
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}",
|
|
262
436
|
privacy: "Structured data and the WeChat import file stay on this computer until you explicitly send the file or scan its data code.",
|
|
263
437
|
},
|
|
264
438
|
};
|
|
@@ -268,6 +442,7 @@ const COMPENSATION_COPY = {
|
|
|
268
442
|
latest: "本单工资",
|
|
269
443
|
session: "本单工资",
|
|
270
444
|
"last-hours": "本段工资",
|
|
445
|
+
"custom-range": "区间工资",
|
|
271
446
|
today: "本日工资",
|
|
272
447
|
"last-7-days": "近七日工资",
|
|
273
448
|
"this-week": "本周工资",
|
|
@@ -278,6 +453,7 @@ const COMPENSATION_COPY = {
|
|
|
278
453
|
latest: "SHIFT PAY",
|
|
279
454
|
session: "SHIFT PAY",
|
|
280
455
|
"last-hours": "WINDOW PAY",
|
|
456
|
+
"custom-range": "RANGE PAY",
|
|
281
457
|
today: "TODAY'S PAY",
|
|
282
458
|
"last-7-days": "7-DAY PAY",
|
|
283
459
|
"this-week": "THIS WEEK'S PAY",
|
|
@@ -306,15 +482,26 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
|
|
|
306
482
|
return RECEIPT_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
|
|
307
483
|
}
|
|
308
484
|
|
|
309
|
-
export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null) {
|
|
310
|
-
const
|
|
311
|
-
|
|
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;
|
|
312
495
|
}
|
|
313
496
|
|
|
314
497
|
export function getRollingSummaryNotice(locale = DEFAULT_LOCALE, hours = null) {
|
|
315
498
|
return String(getReceiptCopy(locale).rollingSummaryNotice).replaceAll("{hours}", String(hours || 3));
|
|
316
499
|
}
|
|
317
500
|
|
|
501
|
+
export function getCustomSummaryNotice(locale = DEFAULT_LOCALE) {
|
|
502
|
+
return getReceiptCopy(locale).customSummaryNotice;
|
|
503
|
+
}
|
|
504
|
+
|
|
318
505
|
export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
|
|
319
506
|
const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
|
|
320
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
|
+
}
|
package/src/core/range.mjs
CHANGED
|
@@ -4,6 +4,7 @@ export const RECEIPT_SCOPES = new Set([
|
|
|
4
4
|
"latest",
|
|
5
5
|
"session",
|
|
6
6
|
"last-hours",
|
|
7
|
+
"custom-range",
|
|
7
8
|
"today",
|
|
8
9
|
"last-7-days",
|
|
9
10
|
"this-week",
|
|
@@ -14,6 +15,8 @@ const SCOPE_ALIASES = new Map([
|
|
|
14
15
|
["session", "session"],
|
|
15
16
|
["hours", "last-hours"],
|
|
16
17
|
["last-hours", "last-hours"],
|
|
18
|
+
["custom", "custom-range"],
|
|
19
|
+
["custom-range", "custom-range"],
|
|
17
20
|
["today", "today"],
|
|
18
21
|
["7d", "last-7-days"],
|
|
19
22
|
["last-7-days", "last-7-days"],
|
|
@@ -47,7 +50,125 @@ function floorToMinute(value) {
|
|
|
47
50
|
return date;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
function parseDateParts(value) {
|
|
54
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || "").trim());
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseDateTimeParts(value) {
|
|
60
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})$/.exec(String(value || "").trim());
|
|
61
|
+
if (!match) return null;
|
|
62
|
+
return {
|
|
63
|
+
year: Number(match[1]),
|
|
64
|
+
month: Number(match[2]),
|
|
65
|
+
day: Number(match[3]),
|
|
66
|
+
hour: Number(match[4]),
|
|
67
|
+
minute: Number(match[5]),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validCalendarParts(parts, includeTime = false) {
|
|
72
|
+
if (!parts) return false;
|
|
73
|
+
const probe = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
|
|
74
|
+
if (
|
|
75
|
+
probe.getUTCFullYear() !== parts.year ||
|
|
76
|
+
probe.getUTCMonth() + 1 !== parts.month ||
|
|
77
|
+
probe.getUTCDate() !== parts.day
|
|
78
|
+
) return false;
|
|
79
|
+
if (!includeTime) return true;
|
|
80
|
+
return parts.hour >= 0 && parts.hour <= 23 && parts.minute >= 0 && parts.minute <= 59;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function zonedParts(date, timezone) {
|
|
84
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
85
|
+
timeZone: timezone,
|
|
86
|
+
year: "numeric",
|
|
87
|
+
month: "2-digit",
|
|
88
|
+
day: "2-digit",
|
|
89
|
+
hour: "2-digit",
|
|
90
|
+
minute: "2-digit",
|
|
91
|
+
second: "2-digit",
|
|
92
|
+
hourCycle: "h23",
|
|
93
|
+
}).formatToParts(date);
|
|
94
|
+
return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, Number(part.value)]));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function zonedDateTime(parts, timezone) {
|
|
98
|
+
const target = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour || 0, parts.minute || 0, 0, 0);
|
|
99
|
+
let result = new Date(target);
|
|
100
|
+
for (let index = 0; index < 3; index += 1) {
|
|
101
|
+
const displayed = zonedParts(result, timezone);
|
|
102
|
+
const displayedUtc = Date.UTC(
|
|
103
|
+
displayed.year,
|
|
104
|
+
displayed.month - 1,
|
|
105
|
+
displayed.day,
|
|
106
|
+
displayed.hour,
|
|
107
|
+
displayed.minute,
|
|
108
|
+
displayed.second,
|
|
109
|
+
);
|
|
110
|
+
result = new Date(result.getTime() + target - displayedUtc);
|
|
111
|
+
}
|
|
112
|
+
const displayed = zonedParts(result, timezone);
|
|
113
|
+
if (
|
|
114
|
+
displayed.year !== parts.year ||
|
|
115
|
+
displayed.month !== parts.month ||
|
|
116
|
+
displayed.day !== parts.day ||
|
|
117
|
+
displayed.hour !== (parts.hour || 0) ||
|
|
118
|
+
displayed.minute !== (parts.minute || 0)
|
|
119
|
+
) throw new Error("自定义时间在所选时区中不存在,请检查夏令时切换或输入格式");
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseCustomRange(from, to, timezone) {
|
|
124
|
+
const fromDate = parseDateParts(from);
|
|
125
|
+
const toDate = parseDateParts(to);
|
|
126
|
+
const fromDateTime = parseDateTimeParts(from);
|
|
127
|
+
const toDateTime = parseDateTimeParts(to);
|
|
128
|
+
|
|
129
|
+
if (fromDate && toDate) {
|
|
130
|
+
if (!validCalendarParts(fromDate) || !validCalendarParts(toDate)) throw new Error("自定义日期无效");
|
|
131
|
+
const startDate = String(from).trim();
|
|
132
|
+
const endDate = String(to).trim();
|
|
133
|
+
if (startDate > endDate) throw new Error("自定义区间的开始日期不能晚于结束日期");
|
|
134
|
+
const nextDate = shiftDateKey(endDate, 1);
|
|
135
|
+
return {
|
|
136
|
+
boundaryKind: "calendar-days",
|
|
137
|
+
startDate,
|
|
138
|
+
endDate,
|
|
139
|
+
startAt: zonedDateTime({ ...fromDate, hour: 0, minute: 0 }, timezone),
|
|
140
|
+
endAt: zonedDateTime({ ...parseDateParts(nextDate), hour: 0, minute: 0 }, timezone),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (fromDateTime && toDateTime) {
|
|
145
|
+
if (!validCalendarParts(fromDateTime, true) || !validCalendarParts(toDateTime, true)) {
|
|
146
|
+
throw new Error("自定义时间无效");
|
|
147
|
+
}
|
|
148
|
+
const startAt = zonedDateTime(fromDateTime, timezone);
|
|
149
|
+
const endAt = zonedDateTime(toDateTime, timezone);
|
|
150
|
+
if (startAt >= endAt) throw new Error("自定义区间的开始时间必须早于结束时间");
|
|
151
|
+
return {
|
|
152
|
+
boundaryKind: "exact-time",
|
|
153
|
+
startDate: dateKey(startAt, timezone),
|
|
154
|
+
endDate: dateKey(endAt, timezone),
|
|
155
|
+
startAt,
|
|
156
|
+
endAt,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new Error("自定义区间请统一使用 YYYY-MM-DD,或 YYYY-MM-DDTHH:mm 格式");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function resolveRange(
|
|
164
|
+
scope,
|
|
165
|
+
timezone,
|
|
166
|
+
now = new Date(),
|
|
167
|
+
sessionId = null,
|
|
168
|
+
hours = null,
|
|
169
|
+
customRange = null,
|
|
170
|
+
projectId = null,
|
|
171
|
+
) {
|
|
51
172
|
const normalizedScope = normalizeScope(scope);
|
|
52
173
|
if (!normalizedScope || !RECEIPT_SCOPES.has(normalizedScope)) {
|
|
53
174
|
throw new Error(`不支持的统计范围:${scope}`);
|
|
@@ -59,6 +180,7 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
|
|
|
59
180
|
let startAt = null;
|
|
60
181
|
let endAt = null;
|
|
61
182
|
let windowHours = null;
|
|
183
|
+
let boundaryKind = null;
|
|
62
184
|
|
|
63
185
|
if (normalizedScope === "last-hours") {
|
|
64
186
|
windowHours = Number(hours ?? 3);
|
|
@@ -69,6 +191,15 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
|
|
|
69
191
|
startAt = new Date(endAt.getTime() - windowHours * 60 * 60 * 1000);
|
|
70
192
|
startDate = dateKey(startAt, timezone);
|
|
71
193
|
endDate = dateKey(endAt, timezone);
|
|
194
|
+
boundaryKind = "exact-time";
|
|
195
|
+
} else if (normalizedScope === "custom-range") {
|
|
196
|
+
if (!customRange?.from || !customRange?.to) throw new Error("自定义区间需要开始和结束时间");
|
|
197
|
+
const parsed = parseCustomRange(customRange.from, customRange.to, timezone);
|
|
198
|
+
startDate = parsed.startDate;
|
|
199
|
+
endDate = parsed.endDate;
|
|
200
|
+
startAt = parsed.startAt;
|
|
201
|
+
endAt = parsed.endAt;
|
|
202
|
+
boundaryKind = parsed.boundaryKind;
|
|
72
203
|
} else if (normalizedScope === "today") {
|
|
73
204
|
startDate = targetDate;
|
|
74
205
|
endDate = targetDate;
|
|
@@ -90,23 +221,27 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
|
|
|
90
221
|
endAt,
|
|
91
222
|
hours: windowHours,
|
|
92
223
|
sessionId: sessionId || null,
|
|
224
|
+
boundaryKind,
|
|
225
|
+
projectId: projectId || null,
|
|
93
226
|
};
|
|
94
227
|
}
|
|
95
228
|
|
|
96
|
-
export function
|
|
97
|
-
return scope === "today" || scope === "last-7-days" || scope === "this-week"
|
|
229
|
+
export function isCalendarRange(range) {
|
|
230
|
+
return range?.scope === "today" || range?.scope === "last-7-days" || range?.scope === "this-week"
|
|
231
|
+
|| (range?.scope === "custom-range" && range?.boundaryKind === "calendar-days");
|
|
98
232
|
}
|
|
99
233
|
|
|
100
|
-
export function
|
|
101
|
-
return scope === "last-hours"
|
|
234
|
+
export function isTimeWindowRange(range) {
|
|
235
|
+
return range?.scope === "last-hours"
|
|
236
|
+
|| (range?.scope === "custom-range" && range?.boundaryKind === "exact-time");
|
|
102
237
|
}
|
|
103
238
|
|
|
104
239
|
export function isDateInRange(date, range) {
|
|
105
240
|
if (!date) return false;
|
|
106
|
-
if (
|
|
107
|
-
return date >= range.startAt && date <= range.endAt;
|
|
241
|
+
if (isTimeWindowRange(range)) {
|
|
242
|
+
return date >= range.startAt && (range.scope === "custom-range" ? date < range.endAt : date <= range.endAt);
|
|
108
243
|
}
|
|
109
|
-
if (!
|
|
244
|
+
if (!isCalendarRange(range)) return true;
|
|
110
245
|
const key = dateKey(date, range.timezone);
|
|
111
246
|
return key >= range.startDate && key <= range.endDate;
|
|
112
247
|
}
|
|
@@ -145,6 +280,18 @@ export function outputSlugForRange(range, receiptId = "") {
|
|
|
145
280
|
: endAt.toISOString().replace(/[-:]/g, "").slice(0, 13);
|
|
146
281
|
return `last-${Number(range?.hours || 3)}-hours-${safeSlugSegment(endStamp, "window")}`;
|
|
147
282
|
}
|
|
283
|
+
if (scope === "custom-range") {
|
|
284
|
+
if (range?.boundaryKind === "calendar-days") {
|
|
285
|
+
const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
|
|
286
|
+
return `custom-${safeSlugSegment(dateSpan, "range")}`;
|
|
287
|
+
}
|
|
288
|
+
const startAt = range?.startAt instanceof Date ? range.startAt : new Date(range?.startAt || 0);
|
|
289
|
+
const endAt = range?.endAt instanceof Date ? range.endAt : new Date(range?.endAt || 0);
|
|
290
|
+
const stamp = (value) => Number.isNaN(value.getTime())
|
|
291
|
+
? "time"
|
|
292
|
+
: value.toISOString().replace(/[-:]/g, "").slice(0, 13);
|
|
293
|
+
return `custom-${safeSlugSegment(`${stamp(startAt)}-to-${stamp(endAt)}`, "range")}`;
|
|
294
|
+
}
|
|
148
295
|
if (scope === "last-7-days" || scope === "this-week") {
|
|
149
296
|
const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
|
|
150
297
|
return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
|