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 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
- - 页面新增 **每日用户消息数** 纵向柱状图:按自然日统计的真实用户请求数,口径与导出契约的 `userMessageCount` 一致(来自 `~/.claude/projects/**/*.jsonl`),不是 statusline 采样数,也仅用于页面展示、不进任何导出/聚合契约
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` 为 `11`。顶层 `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` 单列
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-14`
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` 接受 `schemaVersion: 6/7/8/9/10/11`。v6–v9 继续聚合原统计;存在 API 请求时成本为空并全部计入未定价,没有请求时成本为 0。旧版与 v10 或不同价格目录混用时,`pricingCatalogVersion` 按规则标记为 `mixed`
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.enumerateDateKeys)(window.start, window.end).map((date) => ({
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: 11,
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.enumerateDateKeys)(window.start, window.end).map((date) => {
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: 11,
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;
@@ -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" || ![6, 7, 8, 9, 10, 11].includes(value.schemaVersion)) {
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
- /** 把 bundle 的 rawEvents 计算成 StatuslineEvent 并按本地自然日分组,结果做缓存复用。 */
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 = localDateKey(new Date(event.timestamp));
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) => localDateKey(new Date(event.timestamp)) === date);
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(event.timestamp)),
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.bundle.range.start));
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-14",
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
  }
@@ -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 = localDateKey(new Date(timestamp));
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.localDateKey)(new Date(timestamp)));
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.localDateKey)(new Date(ms))).userMessageCount += 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 的本地日归桶(保证 weekly = Σ daily、
399
- * 且重放副本跨天不重复)。token 维度按 token_count 事件 timestamp 的本地日累加。
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
@@ -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">按自然日统计的真实用户请求数(口径同导出 userMessageCount),共 ${total} 条 Claude · ${codexTotal} 条 Codex</p>
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>
@@ -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.slice(0, 10);
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);
@@ -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
- // 周一(getDay() === 1)是上一周结束后第一个能拿到完整数据的日子:顺带归档 last-week。
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
- const DAY_MS = 24 * HOUR_MS;
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 day = date.getDay();
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
- const start = new Date(date);
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
- * 以周日 23:59:59.999 作为一周结束(周一为起始)。
59
+ * 以下周一统计日开始前 1 毫秒作为一周结束。
34
60
  *
35
61
  * 和 `startOfLocalWeek` 对称,用来把 `this-week` 补齐成完整一周。
36
62
  */
37
- function endOfLocalWeek(date) {
38
- const start = startOfLocalWeek(date);
39
- return new Date(start.getFullYear(), start.getMonth(), start.getDate() + 6, 23, 59, 59, 999);
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: startOfLocalDay(now), end: now };
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 `${localDateKey(start)}_to_${localDateKey(end)}`;
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) => localDateKey(date).replaceAll("-", "_");
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.setTime(cursor.getTime() + DAY_MS);
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.19",
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": [