ccus-cli 0.2.3 → 0.2.5

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
@@ -162,7 +162,7 @@ ccus aggregate serve --input-dir ./team-exports
162
162
  - 默认输出一个 `json` 数据包,里面同时包含 `rawEvents`、`weeklySummary`、`dailySummaries`
163
163
  - 导出文件内容为**紧凑 JSON**(无缩进),并默认 **gzip 压缩**后写成 `.json.gz`;gzip 与紧凑化都只是存储/展示层变化,解压后的字段集合与 `schemaVersion` 不变
164
164
  - 默认写 `.json.gz`;若用 `--out` 指定一个非 `.gz` 结尾的路径,则按明文 JSON 写出(不压缩)
165
- - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `8`:v7 起新增 `weeklySummary.codex` 与 `dailySummaries[].codex`(Codex CLI 的消息/请求/token 统计),v8 起该 codex 段再加 5h/7d 的 peak/latest 额度(从 `source="codex"` 事件重算、与 Claude 额度分开看);aggregate daily/weekly CSV 的主字段(消息/请求/token/额度)已把 Codex **叠加进 Claude 合计**(累加量相加、额度 peak 取两源 max、latest 两源相加、7d 累计含两源读数),不再单列 `codex*` 列,detail.csv 加 `source` 列区分来源
165
+ - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `9`:v7 起新增 `weeklySummary.codex` 与 `dailySummaries[].codex`(Codex CLI 的消息/请求/token 统计),v8 起该 codex 段再加 5h/7d 的 peak/latest 额度(从 `source="codex"` 事件重算、与 Claude 额度分开看),v9 起 codex `inputTokens` 改为净输入(`input_tokens - cached_input_tokens`,不含缓存命中)以对齐 Claude 的 `input_tokens` 口径;aggregate daily/weekly CSV 的主字段(消息/请求/token/额度)已把 Codex **叠加进 Claude 合计**(累加量相加、额度 peak 取两源 max、latest 两源相加、7d 累计含两源读数),不再单列 `codex*` 列,detail.csv 加 `source` 列区分来源
166
166
  - 默认文件名会带 git email 的帐号名前缀和起止日期,例如:`alice_export_2026-05-26_to_2026-06-01.json.gz`
167
167
  - `userMessageCount` 来自 `~/.claude/projects/**/*.jsonl` 的非 meta `type:user` 事件
168
168
  - `apiRequestCount` 与 token 指标来自 `~/.claude/projects/**/*.jsonl` 中带 `message.usage` 的 `type:assistant` 事件
@@ -172,7 +172,7 @@ ccus aggregate serve --input-dir ./team-exports
172
172
  ## 多人汇总
173
173
 
174
174
  - 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
175
- - `aggregate` 接受 `schemaVersion: 6/7/8` 的 bundle(v8 含 codex 额度,v7/v6 容错回退 null/零值);更旧的导出请先用当前版本重新 `ccus export`
175
+ - `aggregate` 接受 `schemaVersion: 6/7/8/9` 的 bundle(v9 codex inputTokens 为净输入,v8 含 codex 额度,v7/v6 容错回退 null/零值);更旧的导出请先用当前版本重新 `ccus export`
176
176
  - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:去重以**天**为粒度,对每个「同人同天」取 `generatedAt` 最新的那份导出,避免同一台机器重复导出或周与周重叠造成翻倍;**周汇总不取整周单份,而是把按天去重后的各天数据上卷累加**,所以多台电脑在不同天产生的用量会正确合进同一周。usage(5h / 7d)从选中事件按真实时间戳重算,Claude+Codex 合并:peak 取两源 max、latest 两源相加
177
177
  - `ccus aggregate --input-dir DIR --out-dir DIR`
178
178
  - 输出三个文件:
package/dist/cli.js CHANGED
@@ -395,7 +395,7 @@ async function runExport(options) {
395
395
  exportUserName = gitIdentity.userName;
396
396
  }
397
397
  const weeklySummary = {
398
- schemaVersion: 8,
398
+ schemaVersion: 9,
399
399
  generatedAt: new Date().toISOString(),
400
400
  range: {
401
401
  label: window.label,
@@ -479,7 +479,7 @@ async function runExport(options) {
479
479
  };
480
480
  });
481
481
  const bundle = {
482
- schemaVersion: 8,
482
+ schemaVersion: 9,
483
483
  generatedAt: new Date().toISOString(),
484
484
  range: {
485
485
  label: window.label,
@@ -50,7 +50,7 @@ function isWeeklyExportBundle(value) {
50
50
  if (!isRecord(value)) {
51
51
  return false;
52
52
  }
53
- if (typeof value.schemaVersion !== "number" || ![6, 7, 8].includes(value.schemaVersion)) {
53
+ if (typeof value.schemaVersion !== "number" || ![6, 7, 8, 9].includes(value.schemaVersion)) {
54
54
  return false;
55
55
  }
56
56
  if (!Array.isArray(value.rawEvents) || !Array.isArray(value.dailySummaries) || !isRecord(value.weeklySummary) || !isRecord(value.identity) || !isRecord(value.range)) {
@@ -131,7 +131,7 @@ async function loadWeeklyExportBundles(inputDir) {
131
131
  }
132
132
  }
133
133
  if (invalidFiles.length > 0) {
134
- throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8 bundles.`);
134
+ throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8/9 bundles.`);
135
135
  }
136
136
  return bundles;
137
137
  }
@@ -47,6 +47,8 @@ function timestampInRange(timestamp, start, end) {
47
47
  /**
48
48
  * 从 `token_count` 事件的 `info.last_token_usage` 取 token 用量对象(缺失返回 null)。
49
49
  * 必须用 last_token_usage(本次增量),不能用 total_token_usage(会话累计,会重复计)。
50
+ * 注意:Codex 的 `input_tokens` 含缓存命中(`cached_input_tokens` 是其子集),
51
+ * 累加 inputTokens 时要减去 cached 得到净输入,对齐 Claude 的 `input_tokens` 口径。
50
52
  */
51
53
  function extractLastTokenUsage(payload) {
52
54
  if (!isRecord(payload.info)) {
@@ -55,16 +57,18 @@ function extractLastTokenUsage(payload) {
55
57
  return isRecord(payload.info.last_token_usage) ? payload.info.last_token_usage : null;
56
58
  }
57
59
  /**
58
- * 统计单个 Codex rollout 文件内的 token / 消息。
60
+ * 解析单个 Codex rollout 文件,收集范围内的 task_started turn 节点与 token_count 用量。
59
61
  *
60
62
  * Codex rollout 每行一个事件,timestamp 在 top-level:
61
- * - `event_msg` + `payload.type=="user_message"`:一次用户消息(工具结果是 `function_call_output`,不命中,无需过滤)。
63
+ * - `event_msg` + `payload.type=="task_started"`:一个用户 turn,带全局唯一 `turn_id`。
64
+ * fork / spawn sub-agent / resume 会把历史 task_started 跨文件重放,故 turn 节点交上层按
65
+ * turn_id 全局去重(取最早 timestamp),不能在单文件层计数。
62
66
  * - `event_msg` + `payload.type=="token_count"`:一次模型请求,token 取 `info.last_token_usage` 增量。
63
67
  */
64
68
  function summarizeRollout(content, start, end) {
65
69
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
66
- const summary = {
67
- userMessageCount: 0,
70
+ const result = {
71
+ turns: [],
68
72
  apiRequestCount: 0,
69
73
  inputTokens: 0,
70
74
  outputTokens: 0,
@@ -80,16 +84,21 @@ function summarizeRollout(content, start, end) {
80
84
  continue;
81
85
  }
82
86
  const payload = record.payload;
83
- if (payload.type === "user_message") {
84
- summary.userMessageCount += 1;
87
+ if (payload.type === "task_started" && typeof payload.turn_id === "string") {
88
+ const ms = new Date(getString(record.timestamp) ?? "").getTime();
89
+ if (Number.isFinite(ms)) {
90
+ result.turns.push({ turnId: payload.turn_id, ms });
91
+ }
85
92
  }
86
93
  if (payload.type === "token_count") {
87
94
  const usage = extractLastTokenUsage(payload);
88
95
  if (usage) {
89
- summary.apiRequestCount += 1;
90
- summary.inputTokens += getNumber(usage.input_tokens);
91
- summary.outputTokens += getNumber(usage.output_tokens);
92
- summary.cacheReadInputTokens += getNumber(usage.cached_input_tokens);
96
+ // 净输入 = input_tokens - cached_input_tokens(input_tokens 含缓存命中,减去对齐 Claude 口径)。
97
+ const netInputTokens = Math.max(0, getNumber(usage.input_tokens) - getNumber(usage.cached_input_tokens));
98
+ result.apiRequestCount += 1;
99
+ result.inputTokens += netInputTokens;
100
+ result.outputTokens += getNumber(usage.output_tokens);
101
+ result.cacheReadInputTokens += getNumber(usage.cached_input_tokens);
93
102
  }
94
103
  }
95
104
  }
@@ -97,48 +106,79 @@ function summarizeRollout(content, start, end) {
97
106
  continue;
98
107
  }
99
108
  }
100
- return summary;
109
+ return result;
101
110
  }
102
111
  /**
103
112
  * 从 Codex 本地 session rollout(<CODEX_HOME>/sessions 下递归的 .jsonl)统计消息数、请求数和 token 用量。
113
+ *
114
+ * 消息数 = task_started 的 distinct turn_id(跨文件去重)。重放副本会让同一 turn_id 出现在多个文件,
115
+ * 故用全局 Map<turn_id, minMs> 收集(取最早 timestamp = 真实发生时刻,早于任何重放副本),最后取 size。
104
116
  */
105
117
  async function summarizeCodexSessionUsage(start, end) {
106
118
  const codexDataDir = (0, paths_1.getCodexHome)();
107
119
  const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
108
120
  const files = await collectRolloutFiles(sessionsDir);
109
- const totals = {
110
- userMessageCount: 0,
111
- apiRequestCount: 0,
112
- inputTokens: 0,
113
- outputTokens: 0,
114
- cacheReadInputTokens: 0,
115
- matchedFileCount: files.length,
116
- codexDataDir,
117
- };
121
+ const turnMinMs = new Map();
122
+ let apiRequestCount = 0;
123
+ let inputTokens = 0;
124
+ let outputTokens = 0;
125
+ let cacheReadInputTokens = 0;
118
126
  for (const filePath of files) {
119
127
  try {
120
128
  const content = await promises_1.default.readFile(filePath, "utf8");
121
- const summary = summarizeRollout(content, start, end);
122
- totals.userMessageCount += summary.userMessageCount;
123
- totals.apiRequestCount += summary.apiRequestCount;
124
- totals.inputTokens += summary.inputTokens;
125
- totals.outputTokens += summary.outputTokens;
126
- totals.cacheReadInputTokens += summary.cacheReadInputTokens;
129
+ const parsed = summarizeRollout(content, start, end);
130
+ for (const turn of parsed.turns) {
131
+ const prev = turnMinMs.get(turn.turnId);
132
+ if (prev === undefined || turn.ms < prev) {
133
+ turnMinMs.set(turn.turnId, turn.ms);
134
+ }
135
+ }
136
+ apiRequestCount += parsed.apiRequestCount;
137
+ inputTokens += parsed.inputTokens;
138
+ outputTokens += parsed.outputTokens;
139
+ cacheReadInputTokens += parsed.cacheReadInputTokens;
127
140
  }
128
141
  catch {
129
142
  continue;
130
143
  }
131
144
  }
132
- return totals;
145
+ return {
146
+ userMessageCount: turnMinMs.size,
147
+ apiRequestCount,
148
+ inputTokens,
149
+ outputTokens,
150
+ cacheReadInputTokens,
151
+ matchedFileCount: files.length,
152
+ codexDataDir,
153
+ };
133
154
  }
134
155
  /**
135
156
  * 按天汇总 Codex session rollout 中的消息数、请求数和 token 用量。
157
+ *
158
+ * 消息数同 weekly:先全局 Map<turn_id, minMs> 去重,再按 minMs 的本地日归桶(保证 weekly = Σ daily、
159
+ * 且重放副本跨天不重复)。token 维度按 token_count 事件 timestamp 的本地日累加。
136
160
  */
137
161
  async function summarizeCodexSessionUsageByDay(start, end) {
138
162
  const codexDataDir = (0, paths_1.getCodexHome)();
139
163
  const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
140
164
  const files = await collectRolloutFiles(sessionsDir);
165
+ const turnMinMs = new Map();
141
166
  const daily = new Map();
167
+ const ensureDay = (date) => {
168
+ let current = daily.get(date);
169
+ if (!current) {
170
+ current = {
171
+ date,
172
+ userMessageCount: 0,
173
+ apiRequestCount: 0,
174
+ inputTokens: 0,
175
+ outputTokens: 0,
176
+ cacheReadInputTokens: 0,
177
+ };
178
+ daily.set(date, current);
179
+ }
180
+ return current;
181
+ };
142
182
  for (const filePath of files) {
143
183
  let content = "";
144
184
  try {
@@ -162,36 +202,36 @@ async function summarizeCodexSessionUsageByDay(start, end) {
162
202
  continue;
163
203
  }
164
204
  const payload = record.payload;
165
- const isUserMessage = payload.type === "user_message";
166
- const usage = payload.type === "token_count" ? extractLastTokenUsage(payload) : null;
167
- if (!isUserMessage && !usage) {
168
- continue;
205
+ const ms = new Date(timestamp).getTime();
206
+ if (payload.type === "task_started" && typeof payload.turn_id === "string") {
207
+ if (Number.isFinite(ms)) {
208
+ const prev = turnMinMs.get(payload.turn_id);
209
+ if (prev === undefined || ms < prev) {
210
+ turnMinMs.set(payload.turn_id, ms);
211
+ }
212
+ }
169
213
  }
170
- const date = (0, time_1.localDateKey)(new Date(timestamp));
171
- const current = daily.get(date) ?? {
172
- date,
173
- userMessageCount: 0,
174
- apiRequestCount: 0,
175
- inputTokens: 0,
176
- outputTokens: 0,
177
- cacheReadInputTokens: 0,
178
- };
179
- if (isUserMessage) {
180
- current.userMessageCount += 1;
214
+ if (payload.type === "token_count") {
215
+ const usage = extractLastTokenUsage(payload);
216
+ if (usage) {
217
+ const current = ensureDay((0, time_1.localDateKey)(new Date(ms)));
218
+ // 同 summarizeRollout:累加净输入(input - cached),对齐 Claude 口径。
219
+ const netInputTokens = Math.max(0, getNumber(usage.input_tokens) - getNumber(usage.cached_input_tokens));
220
+ current.apiRequestCount += 1;
221
+ current.inputTokens += netInputTokens;
222
+ current.outputTokens += getNumber(usage.output_tokens);
223
+ current.cacheReadInputTokens += getNumber(usage.cached_input_tokens);
224
+ }
181
225
  }
182
- if (usage) {
183
- current.apiRequestCount += 1;
184
- current.inputTokens += getNumber(usage.input_tokens);
185
- current.outputTokens += getNumber(usage.output_tokens);
186
- current.cacheReadInputTokens += getNumber(usage.cached_input_tokens);
187
- }
188
- daily.set(date, current);
189
226
  }
190
227
  catch {
191
228
  continue;
192
229
  }
193
230
  }
194
231
  }
232
+ for (const ms of turnMinMs.values()) {
233
+ ensureDay((0, time_1.localDateKey)(new Date(ms))).userMessageCount += 1;
234
+ }
195
235
  return daily;
196
236
  }
197
237
  //# sourceMappingURL=codex-sessions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {