ccus-cli 0.2.19 → 0.2.22
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/README.md +13 -4
- package/dist/cli.js +43 -19
- package/dist/lib/aggregate.js +43 -23
- package/dist/lib/api-pricing-catalog.json +30 -1
- package/dist/lib/claude.js +5 -10
- package/dist/lib/codex-sessions.js +9 -9
- package/dist/lib/config.js +37 -0
- package/dist/lib/dashboard.js +3 -2
- package/dist/lib/export.js +2 -2
- package/dist/lib/storage.js +2 -2
- package/dist/lib/sync.js +7 -5
- package/dist/lib/time.js +61 -21
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ ccus --version # 查看当前安装的版本
|
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
57
|
ccus install
|
|
58
|
+
ccus config # 查看统计日配置,默认 07:00
|
|
59
|
+
ccus config --day-start 07:30 # 修改每天开始时间,支持整点或 HH:mm
|
|
58
60
|
```
|
|
59
61
|
|
|
60
62
|
然后照常使用 Claude Code,statusline 会显示 5 小时额度使用率(`5h`)、7 天额度使用率(`7d`)、context window 占用百分比(`ctx`)、模型名、工作区名,以及当前 git 分支(`⎇ <branch>`,实时读取,非 git 仓库或处于 detached HEAD 时省略该段);原始 payload 也会落到本地日志,供后续 dashboard / export 使用。
|
|
@@ -149,12 +151,16 @@ ccus aggregate serve --input-dir ./team-exports
|
|
|
149
151
|
|
|
150
152
|
`dashboard build/open` 会在看板同目录生成独立 `pricing.html`。两种 `serve` 都通过 `/pricing.html` 提供当前价格页;看板的合计成本卡下可直接进入。`serve` 默认监听 `127.0.0.1` 上的随机端口,并在每次请求时实时读取最新日志生成页面。`dashboard serve` 默认查看 `this-week`(整周)使用量曲线(`build` / `open` 仍默认 `today`),可用 `--range` 覆盖。
|
|
151
153
|
|
|
154
|
+
统计日默认从本地时间 **07:00** 开始,到次日 07:00 前结束。例如 6 月 2 日 06:59 的使用量归到 6 月 1 日,07:00 起归到 6 月 2 日。凌晨查看 `today` 时显示尚未结束的前一统计日;周一开始时间之前仍属于上一周。
|
|
155
|
+
|
|
156
|
+
用 `ccus config --day-start 07:30` 持久配置,`--day-start 0` 恢复零点切日。配置保存到数据目录的 `config.json`,使用自定义目录时加 `--data-dir PATH`。`export`、`dashboard build/open/serve` 和 `sessions` 支持 `--day-start HH:mm` 临时覆盖,自动同步使用持久配置。消息数、token、成本、额度和日/周汇总使用同一边界;`5h`、`24h` 等滚动窗口长度保持不变。日志仍按自然日保存,修改配置后历史数据会在读取时重新归天。
|
|
157
|
+
|
|
152
158
|
其中:
|
|
153
159
|
|
|
154
160
|
- `5 小时使用量百分比` 是 **展示指标**,来自 Claude 自身字段 `rate_limits.five_hour.used_percentage`
|
|
155
161
|
- 使用率趋势图会在同一张图上叠加三条线:实线为 5 小时使用率(`rate_limits.five_hour`)、虚线为 7 天使用率(`rate_limits.seven_day`),两者各自独立按时间桶聚合、共用固定 0–100% 的左侧 Y 轴;紫色「7d 分区叠加累计」是把 7 天额度锯齿波还原成的累计真实使用量(口径同团队看板的 `sevenDayCumulativeUsagePct`),量纲可超 100%、单独走**右侧自适应 Y 轴**,按分段峰谷和单调非递减地累积上升,终点即顶部卡片的累计值
|
|
156
162
|
- **折线与纵向柱状图均由 [uPlot](https://github.com/leeoniya/uPlot) 渲染**:鼠标在绘图区**任意位置**悬停即可通过十字线 + **跟随鼠标的 tooltip** 看到该处各条线的当前读数(不必精确落在采样点上;遇到几乎垂直的窄尖峰,把鼠标移到竖线顶端即可读到峰值);纵向柱还会在每根柱顶标注数值。底部为**自绘图例**(实时读数集中在 tooltip 里)。uPlot 库与样式已**内联进生成的 HTML**,`dashboard build` 产物用 `file://` 离线打开也能完整渲染与交互,不依赖任何 CDN
|
|
157
|
-
- 页面新增 **每日用户消息数**
|
|
163
|
+
- 页面新增 **每日用户消息数** 纵向柱状图:按配置的统计日统计真实用户请求数,口径与导出契约的 `userMessageCount` 一致(来自 `~/.claude/projects/**/*.jsonl`),不是 statusline 采样数,也仅用于页面展示、不进任何导出/聚合契约
|
|
158
164
|
- 跨多天的窗口(如 `this-week` / `last-week`)使用率曲线会自动改用小时桶聚合,避免一周生成上千个点;x 轴刻度由 uPlot 按时间跨度自适应(短窗口显示 `HH:mm` **24 小时制**、跨天的刻度再补一行日期),不用 am/pm
|
|
159
165
|
- 顶部统计卡展示 `Latest 5h usage`、`Peak 5h usage`、`7d 分区叠加累计`(主数值为 7 天额度分区叠加累计真实使用量,小字补充峰值与最新值)、`用户消息数`(窗口内每日真实用户请求数合计)
|
|
160
166
|
- `--range today / this-week / last-week / 24h` 是 **你要查看的采样历史时间窗口**(`last-week` 指上一个完整周一到周日)
|
|
@@ -167,11 +173,13 @@ ccus aggregate serve --input-dir ./team-exports
|
|
|
167
173
|
- 默认输出一个 `json` 数据包,里面同时包含 `rawEvents`、`weeklySummary`、`dailySummaries`
|
|
168
174
|
- 导出文件内容为**紧凑 JSON**(无缩进),并默认 **gzip 压缩**后写成 `.json.gz`;gzip 与紧凑化都只是存储/展示层变化,解压后的字段集合与 `schemaVersion` 不变
|
|
169
175
|
- 默认写 `.json.gz`;若用 `--out` 指定一个非 `.gz` 结尾的路径,则按明文 JSON 写出(不压缩)
|
|
170
|
-
- 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `
|
|
176
|
+
- 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `12`。两处 `range.dayStart` 记录统计日开始时间(如 `07:00`),供聚合沿用导出口径。顶层 `pricing` 记录本地价格目录版本、USD 币种和 `event-time-standard-api` 基准;`weeklySummary.apiEquivalentCost` 与 `dailySummaries[].apiEquivalentCost` 分别包含 `claude`、`codex`、`total` 的金额及定价覆盖度。v10 延续 v9 的 Codex token 口径:`inputTokens = max(0, input_tokens - cached_input_tokens)`,缓存输入由 `cacheReadInputTokens` 单列
|
|
171
177
|
- v11 在有未定价请求的成本结果中增加 `unpricedModels: [{ model, requestCount }]`,按原始模型名累计请求数,模型缺失记为 `null`,无未定价请求时省略。该明细仅写入 JSON,不增加页面或 CSV 字段;v10 导出仍可读取,但没有模型明细。
|
|
172
178
|
- 等效 API 成本按每次请求的模型、发生时间和 token 分类套用内置标准同步 API 价格。Claude 区分输入、输出、缓存读取及 5 分钟/1 小时缓存写入;Codex 区分净输入、缓存输入和输出。它不是 Claude/Codex 订阅账单,也不含税费、折扣、批处理、工具附加费或区域溢价
|
|
173
179
|
- 未知模型不会丢弃:有部分请求可定价时金额是已知小计,页面显示“至少为”;全部请求均无法定价时金额为不可用。个人 dashboard 在顶部第五张卡显示 Claude 与 Codex 的合计成本,不显示来源分项。全部模型价格集中维护在 `src/lib/api-pricing-catalog.json`,价格目录随 ccus 发布,统计过程不联网;个人与多人 dashboard 的合计成本卡下链接独立 `pricing.html` 并在新页面打开,价格页只保留一套标题,Codex 排在 Claude 前并按模型版本从新到旧排列。当前 Claude 目录包含 Opus 4.7、Opus 4.8、Opus 5、Sonnet 5 和 Fable 5;Sonnet 5 按事件时间区分活动价与标准价
|
|
174
|
-
- GPT-6 使用 `gpt-6-astra` 定价,兼容 `gpt-6` 别名及推理强度后缀。自 2026-09-03 起,每百万 token 输入 / 缓存读取 / 输出为 $10 / $1 / $50;输入与缓存读取合计超过 272K 时为 $20 / $2 / $75。来源:[OpenAI 模型定价](https://developers.openai.com/api/docs/models/gpt-6-astra)。价格目录版本为 `2026-09-
|
|
180
|
+
- GPT-6 使用 `gpt-6-astra` 定价,兼容 `gpt-6` 别名及推理强度后缀。自 2026-09-03 起,每百万 token 输入 / 缓存读取 / 输出为 $10 / $1 / $50;输入与缓存读取合计超过 272K 时为 $20 / $2 / $75。来源:[OpenAI 模型定价](https://developers.openai.com/api/docs/models/gpt-6-astra)。价格目录版本为 `2026-09-23`
|
|
181
|
+
- 自 2026-09-22 起,`gpt-6-sol` 每百万 token 输入 / 缓存读取 / 输出为 $2 / $0.20 / $10,`gpt-6-luna` 为 $0.10 / $0.01 / $0.50;输入与缓存读取合计超过 272K 时,分别为 $4 / $0.40 / $15 和 $0.20 / $0.02 / $0.75。均兼容推理强度后缀。来源:[OpenAI 价格表](https://developers.openai.com/api/docs/pricing)。
|
|
182
|
+
- 自 2026-09-22 起,`claude-opus-5-5`(目录名 `claude-opus-5.5`)每百万 token 输入 / 输出 / 缓存读取 / 5 分钟缓存写入 / 1 小时缓存写入为 $4 / $20 / $0.20 / $5 / $8。来源:[官方发布公告](https://www.anthropic.com/claude-opus-5-5)、[缓存计价说明](https://claude.com/blog/what-a-task-costs-on-opus-5-5)。
|
|
175
183
|
- `gpt-5.3-codex-spark` 自 2026-02-12 起按 `gpt-5.3-codex` 价格估算:每百万 token 输入 $1.75、输出 $14、缓存读取 $0.175。这是本项目采用的估算单价,并非 Spark 官方公开 API 定价。
|
|
176
184
|
- 默认文件名会带 git email 的帐号名前缀和起止日期,例如:`alice_export_2026-05-26_to_2026-06-01.json.gz`
|
|
177
185
|
- `userMessageCount` 来自 `~/.claude/projects/**/*.jsonl` 的非 meta `type:user` 事件
|
|
@@ -189,7 +197,8 @@ ccus aggregate serve --input-dir ./team-exports
|
|
|
189
197
|
## 多人汇总
|
|
190
198
|
|
|
191
199
|
- 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
|
|
192
|
-
- `aggregate`
|
|
200
|
+
- `aggregate` 和 `aggregate serve` 自动跳过不支持的导出版本,在 stderr 提示版本号和文件路径,其余有效文件继续统计;支持版本的数据结构校验失败仍报错。
|
|
201
|
+
- `aggregate` 接受 `schemaVersion: 6/7/8/9/10/11/12`。v6–v11 按零点切日,v12 按 `range.dayStart` 切日;同一人同一周的 bundle 必须使用相同开始时间,否则提示统一配置后重新导出。CSV 列集合不变。v6–v9 继续聚合原统计;存在 API 请求时成本为空并全部计入未定价,没有请求时成本为 0。旧版与 v10 或不同价格目录混用时,`pricingCatalogVersion` 按规则标记为 `mixed`
|
|
193
202
|
- 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:去重以**天**为粒度,对每个「同人同天」取 `generatedAt` 最新的那份导出,避免同一台机器重复导出或周与周重叠造成翻倍;**周汇总不取整周单份,而是把按天去重后的各天数据上卷累加**,所以多台电脑在不同天产生的用量会正确合进同一周。usage(5h / 7d)从选中事件按真实时间戳重算,Claude+Codex 合并:peak 取两源 max、latest 两源相加
|
|
194
203
|
- `ccus aggregate --input-dir DIR --out-dir DIR`
|
|
195
204
|
- 输出三个文件:
|
package/dist/cli.js
CHANGED
|
@@ -42,6 +42,7 @@ exports.handleCodexHook = handleCodexHook;
|
|
|
42
42
|
exports.main = main;
|
|
43
43
|
const node_http_1 = __importDefault(require("node:http"));
|
|
44
44
|
const node_path_1 = __importDefault(require("node:path"));
|
|
45
|
+
const config_1 = require("./lib/config");
|
|
45
46
|
const dashboard_1 = require("./lib/dashboard");
|
|
46
47
|
const dashboard_2 = require("./lib/dashboard");
|
|
47
48
|
const aggregate_dashboard_1 = require("./lib/aggregate-dashboard");
|
|
@@ -70,6 +71,7 @@ const api_equivalent_cost_1 = require("./lib/api-equivalent-cost");
|
|
|
70
71
|
const version_1 = require("./lib/version");
|
|
71
72
|
/** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
|
|
72
73
|
function printHelp() {
|
|
74
|
+
process.stdout.write("统计日配置:ccus config [--day-start 07:00] [--data-dir PATH]\n统计命令可用 --day-start HH:mm 临时覆盖,默认 07:00(本地时区)。\n\n");
|
|
73
75
|
process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH] (默认装 Claude statusLine;--codex 改写 Codex 的 ~/.codex/config.toml notify,配 --uninstall 移除)\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把 Claude 与 Codex 的活跃 session 打包成 zip,默认本周)\n ccus sessions repair [codex|claude] [--min-gap 3h] [--dry-run] (按末条记录时间修复 session 修改时间,默认仅跨天)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus api config [--enable|--disable] [--provider zhipu|custom] [--token-env NAME] [--token VAL] [--url URL] [--project P] [--organization O] [--ttl 5m] [--extractor-file FILE] [--data-dir PATH]\n ccus api test [--data-dir PATH] (立即拉取第三方额度并打印,验证配置是否生效)\n ccus api status [--data-dir PATH]\n ccus open [--data-dir PATH] [--print]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
|
|
74
76
|
}
|
|
75
77
|
/** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
|
|
@@ -104,6 +106,19 @@ function getBooleanOption(options, key) {
|
|
|
104
106
|
function getDataDir(options) {
|
|
105
107
|
return node_path_1.default.resolve(getStringOption(options, "data-dir") ?? (0, paths_1.getDefaultDataDir)());
|
|
106
108
|
}
|
|
109
|
+
async function getDayStartMinutes(options) {
|
|
110
|
+
if (options["day-start"] === true)
|
|
111
|
+
throw new Error("--day-start 缺少时间,例如 07:00。");
|
|
112
|
+
const override = getStringOption(options, "day-start");
|
|
113
|
+
return (0, time_1.parseDayStart)(override ?? (await (0, config_1.readConfig)(getDataDir(options))).dayStart);
|
|
114
|
+
}
|
|
115
|
+
async function handleConfig(options) {
|
|
116
|
+
await getDayStartMinutes(options);
|
|
117
|
+
const dataDir = getDataDir(options);
|
|
118
|
+
const value = getStringOption(options, "day-start");
|
|
119
|
+
const dayStart = value === undefined ? (await (0, config_1.readConfig)(dataDir)).dayStart : await (0, config_1.writeDayStart)(dataDir, value);
|
|
120
|
+
process.stdout.write(`每天开始时间:${dayStart}(本地时区)\n`);
|
|
121
|
+
}
|
|
107
122
|
/**
|
|
108
123
|
* statusline 可以从 stdin 输入,也支持测试时通过 `--input` 指向 fixture 文件。
|
|
109
124
|
*/
|
|
@@ -248,7 +263,7 @@ async function handleStatuslineEmit(options) {
|
|
|
248
263
|
}
|
|
249
264
|
}
|
|
250
265
|
/** 统计前静默同步会话修改时间,修复失败时仍尝试原有统计流程。 */
|
|
251
|
-
async function loadSessionUsage(start, end) {
|
|
266
|
+
async function loadSessionUsage(start, end, dayStartMinutes) {
|
|
252
267
|
try {
|
|
253
268
|
const { repairSessionMtimes } = await Promise.resolve().then(() => __importStar(require("./lib/session-mtime")));
|
|
254
269
|
const results = await repairSessionMtimes(false);
|
|
@@ -262,8 +277,8 @@ async function loadSessionUsage(start, end) {
|
|
|
262
277
|
(0, debug_1.debugLog)("sessions", "统计前同步修改时间失败", error);
|
|
263
278
|
}
|
|
264
279
|
return Promise.all([
|
|
265
|
-
(0, claude_1.summarizeClaudeProjectUsageCombined)(start, end),
|
|
266
|
-
(0, codex_sessions_1.summarizeCodexSessionUsageCombined)(start, end),
|
|
280
|
+
(0, claude_1.summarizeClaudeProjectUsageCombined)(start, end, dayStartMinutes),
|
|
281
|
+
(0, codex_sessions_1.summarizeCodexSessionUsageCombined)(start, end, dayStartMinutes),
|
|
267
282
|
]);
|
|
268
283
|
}
|
|
269
284
|
/**
|
|
@@ -275,18 +290,19 @@ async function loadSessionUsage(start, end) {
|
|
|
275
290
|
async function loadDashboardData(options, defaultRange) {
|
|
276
291
|
const dataDir = getDataDir(options);
|
|
277
292
|
const range = getStringOption(options, "range") ?? defaultRange;
|
|
293
|
+
const dayStartMinutes = await getDayStartMinutes(options);
|
|
278
294
|
const now = new Date();
|
|
279
295
|
// this-week 固定补齐到完整一周(周一到周日),即使本周还没过完,曲线 x 轴也按 7 天逐日展示;
|
|
280
296
|
// 其它范围(today / last-week / 5h)不受影响。
|
|
281
|
-
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now));
|
|
297
|
+
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now, dayStartMinutes));
|
|
282
298
|
// events 含 Claude 与 Codex:个人看板额度叠加 Codex(peak max、latest 相加、7d 累计合并),与 aggregate 同口径。
|
|
283
299
|
// codex 消息柱图走 dailyUserMessages,不受影响。
|
|
284
|
-
const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now))
|
|
300
|
+
const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now, dayStartMinutes))
|
|
285
301
|
.map((record) => (0, payload_1.computeStatuslineEvent)(record));
|
|
286
|
-
const [claudeUsage, codexUsage] = await loadSessionUsage(window.start, window.end);
|
|
302
|
+
const [claudeUsage, codexUsage] = await loadSessionUsage(window.start, window.end, dayStartMinutes);
|
|
287
303
|
const claudeDailyUsage = claudeUsage.daily;
|
|
288
304
|
const codexDailyUsage = codexUsage.daily;
|
|
289
|
-
const dailyUserMessages = (0, time_1.
|
|
305
|
+
const dailyUserMessages = (0, time_1.enumerateReportingDateKeys)(window.start, window.end, dayStartMinutes).map((date) => ({
|
|
290
306
|
date,
|
|
291
307
|
userMessageCount: claudeDailyUsage.get(date)?.userMessageCount ?? 0,
|
|
292
308
|
codexUserMessageCount: codexDailyUsage.get(date)?.userMessageCount ?? 0,
|
|
@@ -298,7 +314,7 @@ async function loadDashboardData(options, defaultRange) {
|
|
|
298
314
|
claude: claudeCost,
|
|
299
315
|
codex: codexCost,
|
|
300
316
|
total: (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([claudeCost, codexCost]),
|
|
301
|
-
}, api_equivalent_cost_1.API_PRICING_METADATA.catalogVersion);
|
|
317
|
+
}, api_equivalent_cost_1.API_PRICING_METADATA.catalogVersion, (0, time_1.formatDayStart)(dayStartMinutes));
|
|
302
318
|
return { html, window };
|
|
303
319
|
}
|
|
304
320
|
/** 构建 dashboard 的公共逻辑,build/open 复用同一套生成流程。 */
|
|
@@ -396,6 +412,7 @@ async function handleDashboardServe(options) {
|
|
|
396
412
|
*/
|
|
397
413
|
async function runExport(options) {
|
|
398
414
|
const dataDir = getDataDir(options);
|
|
415
|
+
const dayStartMinutes = await getDayStartMinutes(options);
|
|
399
416
|
const range = getStringOption(options, "range") ?? "this-week";
|
|
400
417
|
const output = getStringOption(options, "out");
|
|
401
418
|
if (options.mode !== undefined) {
|
|
@@ -406,18 +423,18 @@ async function runExport(options) {
|
|
|
406
423
|
}
|
|
407
424
|
const now = new Date();
|
|
408
425
|
// 周度导出固定覆盖完整一周:this-week 即使本周还没过完,文件名与 dailySummaries 也补齐到周日。
|
|
409
|
-
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now));
|
|
426
|
+
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now, dayStartMinutes));
|
|
410
427
|
(0, debug_1.debugLog)("export", "range resolved", { range, label: window.label, start: window.start.toISOString(), end: window.end.toISOString() });
|
|
411
|
-
const records = await (0, storage_1.readEventsForRange)(dataDir, range, now);
|
|
428
|
+
const records = await (0, storage_1.readEventsForRange)(dataDir, range, now, dayStartMinutes);
|
|
412
429
|
const events = records.map((record) => (0, payload_1.computeStatuslineEvent)(record));
|
|
413
430
|
// 按 source 分流:Claude usage 只算 claude 事件,Codex 额度单列(避免 codex 额度污染 claude usage)。
|
|
414
431
|
const claudeEvents = events.filter((event) => !(0, payload_1.isCodexSourceEvent)(event));
|
|
415
432
|
const codexEvents = events.filter(payload_1.isCodexSourceEvent);
|
|
416
433
|
const statuslineSummary = (0, dashboard_2.summarizeEvents)(claudeEvents);
|
|
417
|
-
const statuslineDailyRows = (0, export_1.buildSummaryRows)(claudeEvents);
|
|
434
|
+
const statuslineDailyRows = (0, export_1.buildSummaryRows)(claudeEvents, dayStartMinutes);
|
|
418
435
|
const codexStatuslineSummary = (0, dashboard_2.summarizeEvents)(codexEvents);
|
|
419
|
-
const codexStatuslineDailyRows = (0, export_1.buildSummaryRows)(codexEvents);
|
|
420
|
-
const [claudeScan, codexScan] = await loadSessionUsage(window.start, window.end);
|
|
436
|
+
const codexStatuslineDailyRows = (0, export_1.buildSummaryRows)(codexEvents, dayStartMinutes);
|
|
437
|
+
const [claudeScan, codexScan] = await loadSessionUsage(window.start, window.end, dayStartMinutes);
|
|
421
438
|
const claudeUsage = claudeScan.weekly;
|
|
422
439
|
const claudeDailyUsage = claudeScan.daily;
|
|
423
440
|
const codexUsage = codexScan.weekly;
|
|
@@ -441,12 +458,13 @@ async function runExport(options) {
|
|
|
441
458
|
exportUserName = gitIdentity.userName;
|
|
442
459
|
}
|
|
443
460
|
const weeklySummary = {
|
|
444
|
-
schemaVersion:
|
|
461
|
+
schemaVersion: 12,
|
|
445
462
|
generatedAt: new Date().toISOString(),
|
|
446
463
|
range: {
|
|
447
464
|
label: window.label,
|
|
448
465
|
start: window.start.toISOString(),
|
|
449
466
|
end: window.end.toISOString(),
|
|
467
|
+
dayStart: (0, time_1.formatDayStart)(dayStartMinutes),
|
|
450
468
|
},
|
|
451
469
|
identity: {
|
|
452
470
|
gitUserName: exportUserName,
|
|
@@ -497,7 +515,7 @@ async function runExport(options) {
|
|
|
497
515
|
};
|
|
498
516
|
const statuslineDailyMap = new Map(statuslineDailyRows.map((row) => [row.date, row]));
|
|
499
517
|
const codexStatuslineDailyMap = new Map(codexStatuslineDailyRows.map((row) => [row.date, row]));
|
|
500
|
-
const dailySummaries = (0, time_1.
|
|
518
|
+
const dailySummaries = (0, time_1.enumerateReportingDateKeys)(window.start, window.end, dayStartMinutes).map((date) => {
|
|
501
519
|
const row = statuslineDailyMap.get(date);
|
|
502
520
|
const claudeDay = claudeDailyUsage.get(date);
|
|
503
521
|
const codexDay = codexDailyUsage.get(date);
|
|
@@ -537,12 +555,13 @@ async function runExport(options) {
|
|
|
537
555
|
};
|
|
538
556
|
});
|
|
539
557
|
const bundle = {
|
|
540
|
-
schemaVersion:
|
|
558
|
+
schemaVersion: 12,
|
|
541
559
|
generatedAt: new Date().toISOString(),
|
|
542
560
|
range: {
|
|
543
561
|
label: window.label,
|
|
544
562
|
start: window.start.toISOString(),
|
|
545
563
|
end: window.end.toISOString(),
|
|
564
|
+
dayStart: (0, time_1.formatDayStart)(dayStartMinutes),
|
|
546
565
|
},
|
|
547
566
|
identity: {
|
|
548
567
|
gitUserName: exportUserName,
|
|
@@ -554,7 +573,7 @@ async function runExport(options) {
|
|
|
554
573
|
dailySummaries,
|
|
555
574
|
};
|
|
556
575
|
const content = (0, export_1.buildWeeklyExportBundleJson)(bundle);
|
|
557
|
-
const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end);
|
|
576
|
+
const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end, dayStartMinutes);
|
|
558
577
|
const gitEmailPrefix = (0, time_1.formatGitEmailFilePrefix)(exportUserEmail);
|
|
559
578
|
// 默认导出 gzip 压缩的 bundle(.json.gz)以缩减体积;用户用 --out 指定非 .gz 路径时仍写明文 JSON。
|
|
560
579
|
const defaultFileName = gitEmailPrefix ? `${gitEmailPrefix}_export_${fileLabel}.json.gz` : `export_${fileLabel}.json.gz`;
|
|
@@ -724,10 +743,11 @@ function resolveExportOptions(action, args, rest) {
|
|
|
724
743
|
*/
|
|
725
744
|
async function handleSessions(options) {
|
|
726
745
|
const dataDir = getDataDir(options);
|
|
746
|
+
const dayStartMinutes = await getDayStartMinutes(options);
|
|
727
747
|
const range = getStringOption(options, "range") ?? "this-week";
|
|
728
748
|
const out = getStringOption(options, "out");
|
|
729
749
|
const now = new Date();
|
|
730
|
-
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now));
|
|
750
|
+
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now, dayStartMinutes));
|
|
731
751
|
(0, debug_1.debugLog)("sessions", "range resolved", { range, label: window.label, start: window.start.toISOString(), end: window.end.toISOString() });
|
|
732
752
|
const [claudeSessions, codexSessions] = await Promise.all([
|
|
733
753
|
(0, claude_1.findActiveSessionFiles)(window.start, window.end),
|
|
@@ -748,7 +768,7 @@ async function handleSessions(options) {
|
|
|
748
768
|
const zipBuffer = await buildZipBuffer(entries);
|
|
749
769
|
const gitIdentity = await (0, git_1.readGitIdentity)();
|
|
750
770
|
const userName = (0, time_1.formatGitEmailFilePrefix)(gitIdentity.userEmail) ?? "unknown";
|
|
751
|
-
const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end);
|
|
771
|
+
const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end, dayStartMinutes);
|
|
752
772
|
const defaultFileName = `projects_${fileLabel}_${userName}.zip`;
|
|
753
773
|
const outputPath = node_path_1.default.resolve(out ?? node_path_1.default.join(dataDir, "sessions", defaultFileName));
|
|
754
774
|
const fsNode = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
|
|
@@ -1388,6 +1408,10 @@ async function main(args = process.argv.slice(2)) {
|
|
|
1388
1408
|
await handleOpenDataDir(parseOptions(args.slice(1)));
|
|
1389
1409
|
return;
|
|
1390
1410
|
}
|
|
1411
|
+
if (group === "config") {
|
|
1412
|
+
await handleConfig(parseOptions(args.slice(1)));
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1391
1415
|
if (group === "update") {
|
|
1392
1416
|
await handleUpdate(parseOptions(args.slice(1)));
|
|
1393
1417
|
return;
|
package/dist/lib/aggregate.js
CHANGED
|
@@ -21,6 +21,7 @@ const api_equivalent_cost_1 = require("./api-equivalent-cost");
|
|
|
21
21
|
const payload_1 = require("./payload");
|
|
22
22
|
const time_1 = require("./time");
|
|
23
23
|
const gunzipAsync = (0, node_util_1.promisify)(node_zlib_1.gunzip);
|
|
24
|
+
const SUPPORTED_BUNDLE_SCHEMA_VERSIONS = [6, 7, 8, 9, 10, 11, 12];
|
|
24
25
|
function maxOrNull(values) {
|
|
25
26
|
const numbers = values.filter((v) => v !== null);
|
|
26
27
|
return numbers.length > 0 ? Math.max(...numbers) : null;
|
|
@@ -79,7 +80,7 @@ function isWeeklyExportBundle(value) {
|
|
|
79
80
|
if (!isRecord(value)) {
|
|
80
81
|
return false;
|
|
81
82
|
}
|
|
82
|
-
if (typeof value.schemaVersion !== "number" || !
|
|
83
|
+
if (typeof value.schemaVersion !== "number" || !SUPPORTED_BUNDLE_SCHEMA_VERSIONS.includes(value.schemaVersion)) {
|
|
83
84
|
return false;
|
|
84
85
|
}
|
|
85
86
|
if (!Array.isArray(value.rawEvents) || !Array.isArray(value.dailySummaries) || !isRecord(value.weeklySummary) || !isRecord(value.identity) || !isRecord(value.range)) {
|
|
@@ -88,6 +89,10 @@ function isWeeklyExportBundle(value) {
|
|
|
88
89
|
if (!hasWeeklyStatuslineShape(value.weeklySummary.statusline)) {
|
|
89
90
|
return false;
|
|
90
91
|
}
|
|
92
|
+
if (value.schemaVersion >= 12 &&
|
|
93
|
+
(typeof value.range.dayStart !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(value.range.dayStart))) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
91
96
|
if (value.schemaVersion < 10) {
|
|
92
97
|
return true;
|
|
93
98
|
}
|
|
@@ -95,12 +100,6 @@ function isWeeklyExportBundle(value) {
|
|
|
95
100
|
hasApiEquivalentCostBreakdownShape(value.weeklySummary.apiEquivalentCost) &&
|
|
96
101
|
value.dailySummaries.every((day) => isRecord(day) && hasApiEquivalentCostBreakdownShape(day.apiEquivalentCost)));
|
|
97
102
|
}
|
|
98
|
-
function localDateKey(date) {
|
|
99
|
-
const year = date.getFullYear();
|
|
100
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
101
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
102
|
-
return `${year}-${month}-${day}`;
|
|
103
|
-
}
|
|
104
103
|
function startOfLocalWeek(date) {
|
|
105
104
|
const day = date.getDay();
|
|
106
105
|
const diff = day === 0 ? -6 : 1 - day;
|
|
@@ -109,8 +108,11 @@ function startOfLocalWeek(date) {
|
|
|
109
108
|
start.setHours(0, 0, 0, 0);
|
|
110
109
|
return start;
|
|
111
110
|
}
|
|
112
|
-
function weekKey(date) {
|
|
113
|
-
return localDateKey(startOfLocalWeek(date));
|
|
111
|
+
function weekKey(date, dayStartMinutes = 0) {
|
|
112
|
+
return (0, time_1.localDateKey)(startOfLocalWeek((0, time_1.startOfReportingDay)(date, dayStartMinutes)));
|
|
113
|
+
}
|
|
114
|
+
function bundleDayStartMinutes(bundle) {
|
|
115
|
+
return bundle.schemaVersion >= 12 ? (0, time_1.parseDayStart)(bundle.range.dayStart ?? "") : 0;
|
|
114
116
|
}
|
|
115
117
|
function toPersonKey(gitUserEmail, gitUserName) {
|
|
116
118
|
return (0, time_1.extractGitEmailAccount)(gitUserEmail) ?? gitUserName ?? "unknown";
|
|
@@ -147,6 +149,9 @@ async function loadWeeklyExportBundles(inputDir) {
|
|
|
147
149
|
try {
|
|
148
150
|
const content = await readBundleFileContent(filePath);
|
|
149
151
|
const parsed = JSON.parse(content);
|
|
152
|
+
if (isRecord(parsed) && typeof parsed.schemaVersion === "number" && !SUPPORTED_BUNDLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
|
|
153
|
+
return { filePath, bundle: null, unsupportedVersion: parsed.schemaVersion };
|
|
154
|
+
}
|
|
150
155
|
if (isWeeklyExportBundle(parsed)) {
|
|
151
156
|
return { filePath, bundle: parsed };
|
|
152
157
|
}
|
|
@@ -160,6 +165,10 @@ async function loadWeeklyExportBundles(inputDir) {
|
|
|
160
165
|
for (const result of results) {
|
|
161
166
|
if (result === null)
|
|
162
167
|
continue;
|
|
168
|
+
if ("unsupportedVersion" in result) {
|
|
169
|
+
process.stderr.write(`已忽略不支持的导出版本 schemaVersion=${result.unsupportedVersion}:${result.filePath}\n`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
163
172
|
if (result.bundle === null) {
|
|
164
173
|
invalidFiles.push(result.filePath);
|
|
165
174
|
}
|
|
@@ -168,7 +177,7 @@ async function loadWeeklyExportBundles(inputDir) {
|
|
|
168
177
|
}
|
|
169
178
|
}
|
|
170
179
|
if (invalidFiles.length > 0) {
|
|
171
|
-
throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8/9/10/11 bundles.`);
|
|
180
|
+
throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8/9/10/11/12 bundles.`);
|
|
172
181
|
}
|
|
173
182
|
return bundles;
|
|
174
183
|
}
|
|
@@ -228,11 +237,20 @@ function isBetterCandidate(nextTier, nextMsgCount, nextApiCount, nextGeneratedAt
|
|
|
228
237
|
function selectDailyRepresentatives(bundles) {
|
|
229
238
|
// 收集每个 (personKey, date) 的所有候选
|
|
230
239
|
const candidatesByKey = new Map();
|
|
240
|
+
const weekDayStarts = new Map();
|
|
231
241
|
for (const { filePath, bundle } of bundles) {
|
|
232
242
|
const personKey = bundlePersonKey(bundle);
|
|
233
243
|
const generatedAt = bundle.generatedAt ?? "";
|
|
234
244
|
const eventsByDate = bundleEventsByDate(bundle);
|
|
245
|
+
const dayStartMinutes = bundleDayStartMinutes(bundle);
|
|
235
246
|
for (const day of bundle.dailySummaries) {
|
|
247
|
+
const week = weekKey(new Date(`${day.date}T12:00:00`));
|
|
248
|
+
const weekKeyForPerson = `${personKey}|${week}`;
|
|
249
|
+
const previous = weekDayStarts.get(weekKeyForPerson);
|
|
250
|
+
if (previous !== undefined && previous !== dayStartMinutes) {
|
|
251
|
+
throw new Error(`${personKey} 在 ${week} 这一周的每天开始时间不一致,请使用相同配置重新导出该周数据。`);
|
|
252
|
+
}
|
|
253
|
+
weekDayStarts.set(weekKeyForPerson, dayStartMinutes);
|
|
236
254
|
const key = `${personKey}|${day.date}`;
|
|
237
255
|
const events = eventsByDate.get(day.date) ?? [];
|
|
238
256
|
const sessionIds = new Set(events.map((e) => e.sessionId).filter((s) => s !== null));
|
|
@@ -292,7 +310,7 @@ function selectDailyRepresentatives(bundles) {
|
|
|
292
310
|
}
|
|
293
311
|
return result;
|
|
294
312
|
}
|
|
295
|
-
/**
|
|
313
|
+
/** 按 bundle 的统计日边界分组,旧版沿用自然日。 */
|
|
296
314
|
const bundleEventsCache = new WeakMap();
|
|
297
315
|
function bundleEventsByDate(bundle) {
|
|
298
316
|
const cached = bundleEventsCache.get(bundle);
|
|
@@ -300,9 +318,10 @@ function bundleEventsByDate(bundle) {
|
|
|
300
318
|
return cached;
|
|
301
319
|
}
|
|
302
320
|
const byDate = new Map();
|
|
321
|
+
const dayStartMinutes = bundleDayStartMinutes(bundle);
|
|
303
322
|
for (const record of bundle.rawEvents.filter(isPersistedStatuslineEvent)) {
|
|
304
323
|
const event = (0, payload_1.computeStatuslineEvent)(record);
|
|
305
|
-
const dateKey =
|
|
324
|
+
const dateKey = (0, time_1.reportingDateKey)(new Date(event.timestamp), dayStartMinutes);
|
|
306
325
|
const list = byDate.get(dateKey);
|
|
307
326
|
if (list) {
|
|
308
327
|
list.push(event);
|
|
@@ -502,25 +521,25 @@ function buildPersonSevenDayCurve(bundles) {
|
|
|
502
521
|
personSevenDayCurveCache.set(bundles, curves);
|
|
503
522
|
return curves;
|
|
504
523
|
}
|
|
505
|
-
/**
|
|
506
|
-
function sliceCurveByDate(curve, date) {
|
|
507
|
-
return curve.filter((event) =>
|
|
524
|
+
/** 从合并曲线里切出某统计日的子序列。 */
|
|
525
|
+
function sliceCurveByDate(curve, date, dayStartMinutes) {
|
|
526
|
+
return curve.filter((event) => (0, time_1.reportingDateKey)(new Date(event.timestamp), dayStartMinutes) === date);
|
|
508
527
|
}
|
|
509
528
|
/** 从合并曲线里切出某周(周起始日 key)的子序列。 */
|
|
510
|
-
function sliceCurveByWeek(curve, week) {
|
|
511
|
-
return curve.filter((event) => weekKey(new Date(event.timestamp)) === week);
|
|
529
|
+
function sliceCurveByWeek(curve, week, dayStartMinutes) {
|
|
530
|
+
return curve.filter((event) => weekKey(new Date(event.timestamp), dayStartMinutes) === week);
|
|
512
531
|
}
|
|
513
532
|
/**
|
|
514
533
|
* 分源 7d 累计:Claude 与 Codex 各自按区间切片做分段峰谷和,再用 `addNullable` 相加。
|
|
515
534
|
* 两源是不同额度池的独立曲线,混算会互相触发假 reset 而虚高;分源算后相加,与 CSV 里 token /
|
|
516
535
|
* 消息数「Claude+Codex 合计」口径一致。该 personKey 无任何曲线、或两源都无有效样本时返回 null。
|
|
517
536
|
*/
|
|
518
|
-
function computeCumulativeSevenDayBySource(curves, personKey, slicer, rangeKey) {
|
|
537
|
+
function computeCumulativeSevenDayBySource(curves, personKey, slicer, rangeKey, dayStartMinutes) {
|
|
519
538
|
const bySource = curves.get(personKey);
|
|
520
539
|
if (!bySource) {
|
|
521
540
|
return null;
|
|
522
541
|
}
|
|
523
|
-
return addNullable(computeCumulativeSevenDay(slicer(bySource.claude, rangeKey)), computeCumulativeSevenDay(slicer(bySource.codex, rangeKey)));
|
|
542
|
+
return addNullable(computeCumulativeSevenDay(slicer(bySource.claude, rangeKey, dayStartMinutes)), computeCumulativeSevenDay(slicer(bySource.codex, rangeKey, dayStartMinutes)));
|
|
524
543
|
}
|
|
525
544
|
/** 从一组事件按真实时间戳重算 5h / 7d 的 peak(max)与 latest(时间戳最新非空)。 */
|
|
526
545
|
function recomputeUsage(events) {
|
|
@@ -546,7 +565,7 @@ function buildAggregatedDetailRows(bundles) {
|
|
|
546
565
|
rows.push({
|
|
547
566
|
...event,
|
|
548
567
|
personKey: rep.personKey,
|
|
549
|
-
weekKey: weekKey(new Date(
|
|
568
|
+
weekKey: weekKey(new Date(`${rep.date}T12:00:00`)),
|
|
550
569
|
dateKey: rep.date,
|
|
551
570
|
source: isCodex ? "codex" : "claude",
|
|
552
571
|
// codex 事件无单事件 token 语义(token 在 daySummary.codex 按天聚合),不附 claude 的日总量。
|
|
@@ -630,7 +649,7 @@ function buildAggregatedDailyRows(bundles) {
|
|
|
630
649
|
// 额度回退:rawEvents 缺时从 daySummary 取(Claude+Codex 合并,见 fallbackWeeklyUsage)
|
|
631
650
|
const fallback = fallbackWeeklyUsage(reps.map((r) => r.day));
|
|
632
651
|
// 累计指标走全样本分源曲线(Claude + Codex 各自切片累计后相加),不走单机的 recomputeUsage,避免漏掉另一台机器的样本。
|
|
633
|
-
const sevenDayCumulativeUsagePct = computeCumulativeSevenDayBySource(curves, personKey, sliceCurveByDate, date);
|
|
652
|
+
const sevenDayCumulativeUsagePct = computeCumulativeSevenDayBySource(curves, personKey, sliceCurveByDate, date, bundleDayStartMinutes(reps[0].bundle));
|
|
634
653
|
rows.push({
|
|
635
654
|
personKey,
|
|
636
655
|
date,
|
|
@@ -685,13 +704,14 @@ function buildAggregatedWeeklyRows(bundles) {
|
|
|
685
704
|
const groups = new Map();
|
|
686
705
|
for (const reps of repsMap.values()) {
|
|
687
706
|
for (const rep of reps) {
|
|
688
|
-
const week = weekKey(new Date(rep.
|
|
707
|
+
const week = weekKey(new Date(`${rep.date}T12:00:00`));
|
|
689
708
|
const key = `${rep.personKey}|${week}`;
|
|
690
709
|
let acc = groups.get(key);
|
|
691
710
|
if (!acc) {
|
|
692
711
|
acc = {
|
|
693
712
|
personKey: rep.personKey,
|
|
694
713
|
week,
|
|
714
|
+
dayStartMinutes: bundleDayStartMinutes(rep.bundle),
|
|
695
715
|
userMessageCount: 0,
|
|
696
716
|
apiRequestCount: 0,
|
|
697
717
|
inputTokens: 0,
|
|
@@ -732,7 +752,7 @@ function buildAggregatedWeeklyRows(bundles) {
|
|
|
732
752
|
const fallback = fallbackWeeklyUsage(acc.days);
|
|
733
753
|
const apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)(acc.costContributions.map((contribution) => contribution.result));
|
|
734
754
|
// 整周累计:Claude + Codex 各自在整周子曲线上做分段峰谷和后相加,跨天边界增量被计入,故 weekly ≥ Σ daily。
|
|
735
|
-
const sevenDayCumulativeUsagePct = computeCumulativeSevenDayBySource(curves, acc.personKey, sliceCurveByWeek, acc.week);
|
|
755
|
+
const sevenDayCumulativeUsagePct = computeCumulativeSevenDayBySource(curves, acc.personKey, sliceCurveByWeek, acc.week, acc.dayStartMinutes);
|
|
736
756
|
rows.push({
|
|
737
757
|
personKey: acc.personKey,
|
|
738
758
|
week: acc.week,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"catalogVersion": "2026-09-
|
|
2
|
+
"catalogVersion": "2026-09-23",
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"basis": "event-time-standard-api",
|
|
5
5
|
"entries": [
|
|
@@ -148,6 +148,13 @@
|
|
|
148
148
|
"effectiveTo": null,
|
|
149
149
|
"prices": { "inputUsdPerMillion": 5, "outputUsdPerMillion": 25, "cacheReadInputUsdPerMillion": 0.5, "cacheWrite5mInputUsdPerMillion": 6.25, "cacheWrite1hInputUsdPerMillion": 10 }
|
|
150
150
|
},
|
|
151
|
+
{
|
|
152
|
+
"provider": "claude",
|
|
153
|
+
"model": "claude-opus-5.5",
|
|
154
|
+
"effectiveFrom": "2026-09-22T00:00:00.000Z",
|
|
155
|
+
"effectiveTo": null,
|
|
156
|
+
"prices": { "inputUsdPerMillion": 4, "outputUsdPerMillion": 20, "cacheReadInputUsdPerMillion": 0.2, "cacheWrite5mInputUsdPerMillion": 5, "cacheWrite1hInputUsdPerMillion": 8 }
|
|
157
|
+
},
|
|
151
158
|
{
|
|
152
159
|
"provider": "codex",
|
|
153
160
|
"model": "gpt-5",
|
|
@@ -283,6 +290,28 @@
|
|
|
283
290
|
"thresholdInputTokens": 272000,
|
|
284
291
|
"prices": { "inputUsdPerMillion": 20, "outputUsdPerMillion": 75, "cacheReadInputUsdPerMillion": 2 }
|
|
285
292
|
}
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"provider": "codex",
|
|
296
|
+
"model": "gpt-6-sol",
|
|
297
|
+
"effectiveFrom": "2026-09-22T00:00:00.000Z",
|
|
298
|
+
"effectiveTo": null,
|
|
299
|
+
"prices": { "inputUsdPerMillion": 2, "outputUsdPerMillion": 10, "cacheReadInputUsdPerMillion": 0.2 },
|
|
300
|
+
"longContext": {
|
|
301
|
+
"thresholdInputTokens": 272000,
|
|
302
|
+
"prices": { "inputUsdPerMillion": 4, "outputUsdPerMillion": 15, "cacheReadInputUsdPerMillion": 0.4 }
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"provider": "codex",
|
|
307
|
+
"model": "gpt-6-luna",
|
|
308
|
+
"effectiveFrom": "2026-09-22T00:00:00.000Z",
|
|
309
|
+
"effectiveTo": null,
|
|
310
|
+
"prices": { "inputUsdPerMillion": 0.1, "outputUsdPerMillion": 0.5, "cacheReadInputUsdPerMillion": 0.01 },
|
|
311
|
+
"longContext": {
|
|
312
|
+
"thresholdInputTokens": 272000,
|
|
313
|
+
"prices": { "inputUsdPerMillion": 0.2, "outputUsdPerMillion": 0.75, "cacheReadInputUsdPerMillion": 0.02 }
|
|
314
|
+
}
|
|
286
315
|
}
|
|
287
316
|
]
|
|
288
317
|
}
|
package/dist/lib/claude.js
CHANGED
|
@@ -12,6 +12,7 @@ const promises_1 = __importDefault(require("node:fs/promises"));
|
|
|
12
12
|
const node_path_1 = __importDefault(require("node:path"));
|
|
13
13
|
const api_equivalent_cost_1 = require("./api-equivalent-cost");
|
|
14
14
|
const paths_1 = require("./paths");
|
|
15
|
+
const time_1 = require("./time");
|
|
15
16
|
function isRecord(value) {
|
|
16
17
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
18
|
}
|
|
@@ -80,12 +81,6 @@ function timestampInRange(timestamp, start, end) {
|
|
|
80
81
|
const value = new Date(timestamp).getTime();
|
|
81
82
|
return Number.isFinite(value) && value >= start.getTime() && value <= end.getTime();
|
|
82
83
|
}
|
|
83
|
-
function localDateKey(date) {
|
|
84
|
-
const year = date.getFullYear();
|
|
85
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
86
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
87
|
-
return `${year}-${month}-${day}`;
|
|
88
|
-
}
|
|
89
84
|
async function fileMayContainRange(filePath, start) {
|
|
90
85
|
try {
|
|
91
86
|
return (await promises_1.default.stat(filePath)).mtimeMs >= start.getTime();
|
|
@@ -130,7 +125,7 @@ async function summarizeClaudeProjectUsage(start, end) {
|
|
|
130
125
|
return (await summarizeClaudeProjectUsageCombined(start, end)).weekly;
|
|
131
126
|
}
|
|
132
127
|
/** 单次扫描同时生成周汇总与按天汇总。 */
|
|
133
|
-
async function summarizeClaudeProjectUsageCombined(start, end) {
|
|
128
|
+
async function summarizeClaudeProjectUsageCombined(start, end, dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
134
129
|
const claudeDataDir = (0, paths_1.getClaudeDataDir)();
|
|
135
130
|
const projectDir = node_path_1.default.join(claudeDataDir, "projects");
|
|
136
131
|
const files = await collectProjectJsonlFiles(projectDir);
|
|
@@ -172,7 +167,7 @@ async function summarizeClaudeProjectUsageCombined(start, end) {
|
|
|
172
167
|
if (!isRecord(record) || !timestampInRange(timestamp, start, end)) {
|
|
173
168
|
continue;
|
|
174
169
|
}
|
|
175
|
-
const date =
|
|
170
|
+
const date = (0, time_1.reportingDateKey)(new Date(timestamp), dayStartMinutes);
|
|
176
171
|
const day = daily.get(date) ?? {
|
|
177
172
|
date,
|
|
178
173
|
userMessageCount: 0,
|
|
@@ -260,7 +255,7 @@ async function findActiveSessionFiles(start, end) {
|
|
|
260
255
|
/**
|
|
261
256
|
* 按天汇总 Claude project transcript 中的消息数、请求数、token 用量和标准 API 等效成本。
|
|
262
257
|
*/
|
|
263
|
-
async function summarizeClaudeProjectUsageByDay(start, end) {
|
|
264
|
-
return (await summarizeClaudeProjectUsageCombined(start, end)).daily;
|
|
258
|
+
async function summarizeClaudeProjectUsageByDay(start, end, dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
259
|
+
return (await summarizeClaudeProjectUsageCombined(start, end, dayStartMinutes)).daily;
|
|
265
260
|
}
|
|
266
261
|
//# sourceMappingURL=claude.js.map
|
|
@@ -262,7 +262,7 @@ function ensureCodexDay(daily, date) {
|
|
|
262
262
|
}
|
|
263
263
|
return current;
|
|
264
264
|
}
|
|
265
|
-
function summarizeRollout(content, start, end, daily) {
|
|
265
|
+
function summarizeRollout(content, start, end, daily, dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
266
266
|
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
267
267
|
const result = {
|
|
268
268
|
turns: [],
|
|
@@ -315,7 +315,7 @@ function summarizeRollout(content, start, end, daily) {
|
|
|
315
315
|
result.cacheReadInputTokens += cacheReadInputTokens;
|
|
316
316
|
result.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([result.apiEquivalentCost, cost]);
|
|
317
317
|
if (daily) {
|
|
318
|
-
const day = ensureCodexDay(daily, (0, time_1.
|
|
318
|
+
const day = ensureCodexDay(daily, (0, time_1.reportingDateKey)(new Date(timestamp), dayStartMinutes));
|
|
319
319
|
day.apiRequestCount += 1;
|
|
320
320
|
day.inputTokens += inputTokens;
|
|
321
321
|
day.outputTokens += outputTokens;
|
|
@@ -338,7 +338,7 @@ function summarizeRollout(content, start, end, daily) {
|
|
|
338
338
|
* 消息数 = task_started 的 distinct turn_id(跨文件去重)。重放副本会让同一 turn_id 出现在多个文件,
|
|
339
339
|
* 故用全局 Map<turn_id, minMs> 收集(取最早 timestamp = 真实发生时刻,早于任何重放副本),最后取 size。
|
|
340
340
|
*/
|
|
341
|
-
async function summarizeCodexSessionUsageCombined(start, end) {
|
|
341
|
+
async function summarizeCodexSessionUsageCombined(start, end, dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
342
342
|
const codexDataDir = (0, paths_1.getCodexHome)();
|
|
343
343
|
const groups = await collectRolloutFileGroups();
|
|
344
344
|
const turnMinMs = new Map();
|
|
@@ -354,7 +354,7 @@ async function summarizeCodexSessionUsageCombined(start, end) {
|
|
|
354
354
|
if (content === null || isGuardianRollout(content)) {
|
|
355
355
|
continue;
|
|
356
356
|
}
|
|
357
|
-
const parsed = summarizeRollout(content, start, end, daily);
|
|
357
|
+
const parsed = summarizeRollout(content, start, end, daily, dayStartMinutes);
|
|
358
358
|
for (const turn of parsed.turns) {
|
|
359
359
|
const previous = turnMinMs.get(turn.turnId);
|
|
360
360
|
if (previous === undefined || turn.ms < previous) {
|
|
@@ -372,7 +372,7 @@ async function summarizeCodexSessionUsageCombined(start, end) {
|
|
|
372
372
|
}
|
|
373
373
|
}
|
|
374
374
|
for (const ms of turnMinMs.values()) {
|
|
375
|
-
ensureCodexDay(daily, (0, time_1.
|
|
375
|
+
ensureCodexDay(daily, (0, time_1.reportingDateKey)(new Date(ms), dayStartMinutes)).userMessageCount += 1;
|
|
376
376
|
}
|
|
377
377
|
return {
|
|
378
378
|
weekly: {
|
|
@@ -395,10 +395,10 @@ async function summarizeCodexSessionUsage(start, end) {
|
|
|
395
395
|
* 按天汇总 Codex session rollout 中的消息数、请求数、token 用量和标准 API 等效成本。
|
|
396
396
|
* 与周汇总一致,排除 Codex Desktop 的 guardian 安全审查 rollout。
|
|
397
397
|
*
|
|
398
|
-
* 消息数同 weekly:先全局 Map<turn_id, minMs> 去重,再按 minMs
|
|
399
|
-
* 且重放副本跨天不重复)。token
|
|
398
|
+
* 消息数同 weekly:先全局 Map<turn_id, minMs> 去重,再按 minMs 的统计日归桶(保证 weekly = Σ daily、
|
|
399
|
+
* 且重放副本跨天不重复)。token 按 token_count 事件 timestamp 的统计日累加。
|
|
400
400
|
*/
|
|
401
|
-
async function summarizeCodexSessionUsageByDay(start, end) {
|
|
402
|
-
return (await summarizeCodexSessionUsageCombined(start, end)).daily;
|
|
401
|
+
async function summarizeCodexSessionUsageByDay(start, end, dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
402
|
+
return (await summarizeCodexSessionUsageCombined(start, end, dayStartMinutes)).daily;
|
|
403
403
|
}
|
|
404
404
|
//# sourceMappingURL=codex-sessions.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readConfig = readConfig;
|
|
7
|
+
exports.writeDayStart = writeDayStart;
|
|
8
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const time_1 = require("./time");
|
|
11
|
+
/** 配置缺失时使用 07:00;损坏配置报错,避免悄悄改变统计口径。 */
|
|
12
|
+
async function readConfig(dataDir) {
|
|
13
|
+
let config = {};
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(await promises_1.default.readFile(node_path_1.default.join(dataDir, "config.json"), "utf8"));
|
|
16
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
17
|
+
throw new Error("ccus config.json 必须是 JSON 对象。");
|
|
18
|
+
}
|
|
19
|
+
config = parsed;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "ENOENT")
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (config.dayStart !== undefined && typeof config.dayStart !== "string") {
|
|
26
|
+
throw new Error("config.json 的 dayStart 必须是时间字符串,例如 07:00。");
|
|
27
|
+
}
|
|
28
|
+
return { ...config, dayStart: (0, time_1.formatDayStart)((0, time_1.parseDayStart)(config.dayStart ?? "07:00")) };
|
|
29
|
+
}
|
|
30
|
+
async function writeDayStart(dataDir, value) {
|
|
31
|
+
const dayStart = (0, time_1.formatDayStart)((0, time_1.parseDayStart)(value));
|
|
32
|
+
const config = await readConfig(dataDir);
|
|
33
|
+
await promises_1.default.mkdir(dataDir, { recursive: true });
|
|
34
|
+
await promises_1.default.writeFile(node_path_1.default.join(dataDir, "config.json"), `${JSON.stringify({ ...config, dayStart }, null, 2)}\n`, "utf8");
|
|
35
|
+
return dayStart;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=config.js.map
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -206,7 +206,7 @@ function renderDailyMessages(points) {
|
|
|
206
206
|
<p class="eyebrow">Daily Messages</p>
|
|
207
207
|
<h2>每日用户消息数</h2>
|
|
208
208
|
</div>
|
|
209
|
-
<p class="muted"
|
|
209
|
+
<p class="muted">按配置的统计日统计真实用户请求数,共 ${total} 条 Claude · ${codexTotal} 条 Codex</p>
|
|
210
210
|
</div>
|
|
211
211
|
${noData}
|
|
212
212
|
${chart}
|
|
@@ -294,7 +294,7 @@ function renderRecentEvents(events) {
|
|
|
294
294
|
*
|
|
295
295
|
* 整个 dashboard 保持“单文件可打开”,方便导出和直接分享本地结果。
|
|
296
296
|
*/
|
|
297
|
-
function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages = [], apiEquivalentCost = null, pricingCatalogVersion = null) {
|
|
297
|
+
function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages = [], apiEquivalentCost = null, pricingCatalogVersion = null, dayStart = "07:00") {
|
|
298
298
|
const summary = summarizeEvents(events);
|
|
299
299
|
const buckets = bucketizeEvents(events, start, end, pickBucketMinutes(start, end));
|
|
300
300
|
const totalUserMessages = dailyUserMessages.reduce((sum, point) => sum + point.userMessageCount, 0);
|
|
@@ -470,6 +470,7 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
|
|
|
470
470
|
<p class="subtitle">围绕 Claude Code statusline 的本地采样面板。每次刷新记录一条事件,再把 Claude 自带的 5 小时使用率百分比随时间的变化绘制成静态 Web 页面,适合快速回看与导出周报。</p>
|
|
471
471
|
<div class="hero-meta">
|
|
472
472
|
<span class="hero-chip">时间范围:${escapeHtml(rangeLabel)}</span>
|
|
473
|
+
<span class="hero-chip">每天开始:${escapeHtml(dayStart)}(本地时区)</span>
|
|
473
474
|
<span class="hero-chip">开始:${escapeHtml((0, time_1.formatLocalTimestamp)(start))}</span>
|
|
474
475
|
<span class="hero-chip">结束:${escapeHtml((0, time_1.formatLocalTimestamp)(end))}</span>
|
|
475
476
|
<span class="hero-chip">事件:${summary.sampleCount}</span>
|
package/dist/lib/export.js
CHANGED
|
@@ -143,10 +143,10 @@ function buildWeeklyExportBundleJson(bundle) {
|
|
|
143
143
|
return `${JSON.stringify(bundle)}\n`;
|
|
144
144
|
}
|
|
145
145
|
/** 按天汇总 usage 数据,生成 summary 模式的中间结果。 */
|
|
146
|
-
function buildSummaryRows(events) {
|
|
146
|
+
function buildSummaryRows(events, dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
147
147
|
const grouped = new Map();
|
|
148
148
|
for (const event of events) {
|
|
149
|
-
const key = event.timestamp
|
|
149
|
+
const key = (0, time_1.reportingDateKey)(new Date(event.timestamp), dayStartMinutes);
|
|
150
150
|
const items = grouped.get(key);
|
|
151
151
|
if (items) {
|
|
152
152
|
items.push(event);
|
package/dist/lib/storage.js
CHANGED
|
@@ -195,8 +195,8 @@ async function readEventsFromDayDirectory(directoryPath) {
|
|
|
195
195
|
*
|
|
196
196
|
* 同时兼容“按天目录 JSON 文件”和“旧版 JSONL 文件”两种存储布局。
|
|
197
197
|
*/
|
|
198
|
-
async function readEventsForRange(dataDir, range, now = new Date()) {
|
|
199
|
-
const window = (0, time_1.resolveRange)(range, now);
|
|
198
|
+
async function readEventsForRange(dataDir, range, now = new Date(), dayStartMinutes = time_1.DEFAULT_DAY_START_MINUTES) {
|
|
199
|
+
const window = (0, time_1.resolveRange)(range, now, dayStartMinutes);
|
|
200
200
|
const keys = (0, time_1.enumerateDateKeys)(window.start, window.end);
|
|
201
201
|
const eventsDir = (0, paths_1.getEventsDir)(dataDir);
|
|
202
202
|
const lists = await Promise.all(keys.map(async (key) => {
|
package/dist/lib/sync.js
CHANGED
|
@@ -18,6 +18,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
18
18
|
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
19
19
|
const node_path_1 = __importDefault(require("node:path"));
|
|
20
20
|
const debug_1 = require("./debug");
|
|
21
|
+
const config_1 = require("./config");
|
|
21
22
|
const paths_1 = require("./paths");
|
|
22
23
|
const time_1 = require("./time");
|
|
23
24
|
/** 默认同步周期标签:每 3 小时最多同步一次(滚动 TTL)。 */
|
|
@@ -171,7 +172,7 @@ function formatSyncElapsed(elapsedMs) {
|
|
|
171
172
|
*/
|
|
172
173
|
async function exportAndCopy(dataDir, runExport, targetDir, range, suffix) {
|
|
173
174
|
const { outputPath, window } = await runExport({ "data-dir": dataDir, range });
|
|
174
|
-
const weekDir = (0, time_1.formatWeekDirName)(window.start, window.end);
|
|
175
|
+
const weekDir = (0, time_1.formatWeekDirName)(window.start, window.end, window.dayStartMinutes ?? 0);
|
|
175
176
|
const targetWeekDir = node_path_1.default.join(targetDir, weekDir);
|
|
176
177
|
await promises_1.default.mkdir(targetWeekDir, { recursive: true });
|
|
177
178
|
// 后缀只加在目标目录的副本上,本地 exports 原文件名保持不变。
|
|
@@ -196,12 +197,13 @@ async function performSync(dataDir, runExport, now = new Date()) {
|
|
|
196
197
|
const state = readSyncStateSync(dataDir);
|
|
197
198
|
let lastArchivedWeek = state?.lastArchivedWeek;
|
|
198
199
|
try {
|
|
200
|
+
const dayStartMinutes = (0, time_1.parseDayStart)((await (0, config_1.readConfig)(dataDir)).dayStart);
|
|
199
201
|
const primary = await exportAndCopy(dataDir, runExport, config.targetDir, config.range, config.suffix);
|
|
200
|
-
//
|
|
202
|
+
// 统计日进入周一后才归档,避免周一凌晨提前归档尚未结束的一周。
|
|
201
203
|
let archivedLastWeekDest = null;
|
|
202
|
-
if (now.getDay() === 1) {
|
|
203
|
-
const lastWeek = (0, time_1.resolveRange)("last-week", now);
|
|
204
|
-
const lastWeekDir = (0, time_1.formatWeekDirName)(lastWeek.start, lastWeek.end);
|
|
204
|
+
if ((0, time_1.startOfReportingDay)(now, dayStartMinutes).getDay() === 1) {
|
|
205
|
+
const lastWeek = (0, time_1.resolveRange)("last-week", now, dayStartMinutes);
|
|
206
|
+
const lastWeekDir = (0, time_1.formatWeekDirName)(lastWeek.start, lastWeek.end, dayStartMinutes);
|
|
205
207
|
if (lastArchivedWeek !== lastWeekDir) {
|
|
206
208
|
const archived = await exportAndCopy(dataDir, runExport, config.targetDir, "last-week", config.suffix);
|
|
207
209
|
lastArchivedWeek = archived.weekDir;
|
package/dist/lib/time.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_DAY_START_MINUTES = void 0;
|
|
4
|
+
exports.parseDayStart = parseDayStart;
|
|
5
|
+
exports.formatDayStart = formatDayStart;
|
|
6
|
+
exports.startOfReportingDay = startOfReportingDay;
|
|
7
|
+
exports.reportingDateKey = reportingDateKey;
|
|
3
8
|
exports.resolveRange = resolveRange;
|
|
4
9
|
exports.expandToFullWeekWindow = expandToFullWeekWindow;
|
|
5
10
|
exports.localDateKey = localDateKey;
|
|
@@ -8,11 +13,33 @@ exports.formatWeekDirName = formatWeekDirName;
|
|
|
8
13
|
exports.extractGitEmailAccount = extractGitEmailAccount;
|
|
9
14
|
exports.formatGitEmailFilePrefix = formatGitEmailFilePrefix;
|
|
10
15
|
exports.enumerateDateKeys = enumerateDateKeys;
|
|
16
|
+
exports.enumerateReportingDateKeys = enumerateReportingDateKeys;
|
|
11
17
|
exports.formatLocalTimestamp = formatLocalTimestamp;
|
|
12
18
|
exports.formatClock = formatClock;
|
|
13
19
|
exports.roundNumber = roundNumber;
|
|
14
20
|
const HOUR_MS = 60 * 60 * 1000;
|
|
15
|
-
|
|
21
|
+
exports.DEFAULT_DAY_START_MINUTES = 7 * 60;
|
|
22
|
+
/** 支持整点或 HH:mm,拒绝越界时间。 */
|
|
23
|
+
function parseDayStart(value) {
|
|
24
|
+
const match = /^(\d{1,2})(?::([0-5]\d))?$/.exec(value.trim());
|
|
25
|
+
if (!match || Number(match[1]) > 23) {
|
|
26
|
+
throw new Error("每天开始时间必须是 0–23 的整点或 HH:mm,例如 7、07:00、07:30。");
|
|
27
|
+
}
|
|
28
|
+
return Number(match[1]) * 60 + Number(match[2] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
function formatDayStart(minutes) {
|
|
31
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
|
32
|
+
}
|
|
33
|
+
/** 返回时间所在统计日的起点;边界前归到前一天。 */
|
|
34
|
+
function startOfReportingDay(date, dayStartMinutes = exports.DEFAULT_DAY_START_MINUTES) {
|
|
35
|
+
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, dayStartMinutes);
|
|
36
|
+
return date.getTime() < start.getTime()
|
|
37
|
+
? new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1, 0, dayStartMinutes)
|
|
38
|
+
: start;
|
|
39
|
+
}
|
|
40
|
+
function reportingDateKey(date, dayStartMinutes = exports.DEFAULT_DAY_START_MINUTES) {
|
|
41
|
+
return localDateKey(startOfReportingDay(date, dayStartMinutes));
|
|
42
|
+
}
|
|
16
43
|
/**
|
|
17
44
|
* 统一按本地时区切日,避免“今天 / 本周”与用户直觉不一致。
|
|
18
45
|
*/
|
|
@@ -22,21 +49,21 @@ function startOfLocalDay(date) {
|
|
|
22
49
|
/**
|
|
23
50
|
* 以周一作为一周开始,便于输出 `this-week` 统计。
|
|
24
51
|
*/
|
|
25
|
-
function startOfLocalWeek(date) {
|
|
26
|
-
const
|
|
52
|
+
function startOfLocalWeek(date, dayStartMinutes) {
|
|
53
|
+
const start = startOfReportingDay(date, dayStartMinutes);
|
|
54
|
+
const day = start.getDay();
|
|
27
55
|
const diff = day === 0 ? -6 : 1 - day;
|
|
28
|
-
|
|
29
|
-
start.setDate(date.getDate() + diff);
|
|
30
|
-
return startOfLocalDay(start);
|
|
56
|
+
return new Date(start.getFullYear(), start.getMonth(), start.getDate() + diff, 0, dayStartMinutes);
|
|
31
57
|
}
|
|
32
58
|
/**
|
|
33
|
-
*
|
|
59
|
+
* 以下周一统计日开始前 1 毫秒作为一周结束。
|
|
34
60
|
*
|
|
35
61
|
* 和 `startOfLocalWeek` 对称,用来把 `this-week` 补齐成完整一周。
|
|
36
62
|
*/
|
|
37
|
-
function endOfLocalWeek(date) {
|
|
38
|
-
const start = startOfLocalWeek(date);
|
|
39
|
-
|
|
63
|
+
function endOfLocalWeek(date, dayStartMinutes) {
|
|
64
|
+
const start = startOfLocalWeek(date, dayStartMinutes);
|
|
65
|
+
const next = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 7, 0, dayStartMinutes);
|
|
66
|
+
return new Date(next.getTime() - 1);
|
|
40
67
|
}
|
|
41
68
|
/** range 短别名:保持解析后的 label 仍为规范名,避免影响文件名与 bundle 契约。 */
|
|
42
69
|
const RANGE_ALIASES = {
|
|
@@ -48,21 +75,22 @@ const RANGE_ALIASES = {
|
|
|
48
75
|
*
|
|
49
76
|
* 当前支持:`today`、`this-week`(简写 `tw`)、`last-week`(简写 `lw`)、`5h`、`30m` 这类相对窗口。
|
|
50
77
|
*/
|
|
51
|
-
function resolveRange(range, now = new Date()) {
|
|
78
|
+
function resolveRange(range, now = new Date(), dayStartMinutes = exports.DEFAULT_DAY_START_MINUTES) {
|
|
52
79
|
const raw = (range ?? "5h").trim().toLowerCase();
|
|
53
80
|
const normalized = RANGE_ALIASES[raw] ?? raw;
|
|
54
81
|
if (normalized === "today") {
|
|
55
|
-
return { label: "today", start:
|
|
82
|
+
return { label: "today", start: startOfReportingDay(now, dayStartMinutes), end: now, dayStartMinutes };
|
|
56
83
|
}
|
|
57
84
|
if (normalized === "this-week") {
|
|
58
|
-
return { label: "this-week", start: startOfLocalWeek(now), end: now };
|
|
85
|
+
return { label: "this-week", start: startOfLocalWeek(now, dayStartMinutes), end: now, dayStartMinutes };
|
|
59
86
|
}
|
|
60
87
|
if (normalized === "last-week") {
|
|
61
|
-
const thisWeekStart = startOfLocalWeek(now);
|
|
88
|
+
const thisWeekStart = startOfLocalWeek(now, dayStartMinutes);
|
|
62
89
|
const start = new Date(thisWeekStart);
|
|
63
90
|
start.setDate(start.getDate() - 7);
|
|
91
|
+
start.setHours(0, dayStartMinutes, 0, 0);
|
|
64
92
|
const end = new Date(thisWeekStart.getTime() - 1);
|
|
65
|
-
return { label: "last-week", start, end };
|
|
93
|
+
return { label: "last-week", start, end, dayStartMinutes };
|
|
66
94
|
}
|
|
67
95
|
const match = normalized.match(/^(\d+)([hm])$/);
|
|
68
96
|
if (!match) {
|
|
@@ -75,6 +103,7 @@ function resolveRange(range, now = new Date()) {
|
|
|
75
103
|
label: normalized,
|
|
76
104
|
start: new Date(now.getTime() - duration),
|
|
77
105
|
end: now,
|
|
106
|
+
dayStartMinutes,
|
|
78
107
|
};
|
|
79
108
|
}
|
|
80
109
|
/**
|
|
@@ -88,7 +117,7 @@ function expandToFullWeekWindow(window) {
|
|
|
88
117
|
if (window.label !== "this-week") {
|
|
89
118
|
return window;
|
|
90
119
|
}
|
|
91
|
-
return { ...window, end: endOfLocalWeek(window.start) };
|
|
120
|
+
return { ...window, end: endOfLocalWeek(window.start, window.dayStartMinutes ?? exports.DEFAULT_DAY_START_MINUTES) };
|
|
92
121
|
}
|
|
93
122
|
/**
|
|
94
123
|
* 把日期映射成稳定的本地日键,用于事件按天分桶存储。
|
|
@@ -102,8 +131,8 @@ function localDateKey(date) {
|
|
|
102
131
|
/**
|
|
103
132
|
* 把时间窗口渲染成用于文件名的起止日期标记。
|
|
104
133
|
*/
|
|
105
|
-
function formatRangeFileLabel(start, end) {
|
|
106
|
-
return `${
|
|
134
|
+
function formatRangeFileLabel(start, end, dayStartMinutes = 0) {
|
|
135
|
+
return `${reportingDateKey(start, dayStartMinutes)}_to_${reportingDateKey(end, dayStartMinutes)}`;
|
|
107
136
|
}
|
|
108
137
|
/**
|
|
109
138
|
* 生成定时同步目标目录下「按周」子目录名:年_月_日_年_月_日(全下划线)。
|
|
@@ -111,8 +140,8 @@ function formatRangeFileLabel(start, end) {
|
|
|
111
140
|
* 与 formatRangeFileLabel 同源(都基于 localDateKey 的周一~周日窗口),
|
|
112
141
|
* 但分隔符统一成下划线,例如 `2026_06_01_2026_06_07`。
|
|
113
142
|
*/
|
|
114
|
-
function formatWeekDirName(start, end) {
|
|
115
|
-
const normalize = (date) =>
|
|
143
|
+
function formatWeekDirName(start, end, dayStartMinutes = 0) {
|
|
144
|
+
const normalize = (date) => reportingDateKey(date, dayStartMinutes).replaceAll("-", "_");
|
|
116
145
|
return `${normalize(start)}_${normalize(end)}`;
|
|
117
146
|
}
|
|
118
147
|
/**
|
|
@@ -154,7 +183,18 @@ function enumerateDateKeys(start, end) {
|
|
|
154
183
|
const cursor = startOfLocalDay(start);
|
|
155
184
|
while (cursor.getTime() <= end.getTime()) {
|
|
156
185
|
keys.push(localDateKey(cursor));
|
|
157
|
-
cursor.
|
|
186
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
187
|
+
}
|
|
188
|
+
return keys;
|
|
189
|
+
}
|
|
190
|
+
/** 枚举统计日;存储读取继续使用自然日枚举。 */
|
|
191
|
+
function enumerateReportingDateKeys(start, end, dayStartMinutes = exports.DEFAULT_DAY_START_MINUTES) {
|
|
192
|
+
const keys = [];
|
|
193
|
+
const cursor = startOfReportingDay(start, dayStartMinutes);
|
|
194
|
+
while (cursor.getTime() <= end.getTime()) {
|
|
195
|
+
keys.push(localDateKey(cursor));
|
|
196
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
197
|
+
cursor.setHours(0, dayStartMinutes, 0, 0);
|
|
158
198
|
}
|
|
159
199
|
return keys;
|
|
160
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccus-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"build": "tsc -p tsconfig.json && node scripts/copy-vendor.js",
|
|
18
18
|
"prepublishOnly": "npm run build",
|
|
19
19
|
"test": "node --test --test-concurrency 1 \"dist/test/**/*.test.js\"",
|
|
20
|
-
"test:src": "node --import tsx --test --test-concurrency 1 src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/api-equivalent-cost.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/update-check.test.ts src/test/sync.test.ts src/test/scheduler.test.ts src/test/api-mode.test.ts src/test/codex-fetcher.test.ts src/test/codex-sessions.test.ts src/test/sessions.test.ts src/test/codex-install.test.ts src/test/cli-codex-notify.test.ts src/test/cli-codex-hook.test.ts src/test/cli-install-codex.test.ts",
|
|
20
|
+
"test:src": "node --import tsx --test --test-concurrency 1 src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/api-equivalent-cost.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/day-start.test.ts src/test/update-check.test.ts src/test/sync.test.ts src/test/scheduler.test.ts src/test/api-mode.test.ts src/test/codex-fetcher.test.ts src/test/codex-sessions.test.ts src/test/sessions.test.ts src/test/codex-install.test.ts src/test/cli-codex-notify.test.ts src/test/cli-codex-hook.test.ts src/test/cli-install-codex.test.ts",
|
|
21
21
|
"smoke:api": "node scripts/smoke-api.mjs"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|