ccus-cli 0.2.3 → 0.2.4

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.
@@ -55,16 +55,18 @@ function extractLastTokenUsage(payload) {
55
55
  return isRecord(payload.info.last_token_usage) ? payload.info.last_token_usage : null;
56
56
  }
57
57
  /**
58
- * 统计单个 Codex rollout 文件内的 token / 消息。
58
+ * 解析单个 Codex rollout 文件,收集范围内的 task_started turn 节点与 token_count 用量。
59
59
  *
60
60
  * Codex rollout 每行一个事件,timestamp 在 top-level:
61
- * - `event_msg` + `payload.type=="user_message"`:一次用户消息(工具结果是 `function_call_output`,不命中,无需过滤)。
61
+ * - `event_msg` + `payload.type=="task_started"`:一个用户 turn,带全局唯一 `turn_id`。
62
+ * fork / spawn sub-agent / resume 会把历史 task_started 跨文件重放,故 turn 节点交上层按
63
+ * turn_id 全局去重(取最早 timestamp),不能在单文件层计数。
62
64
  * - `event_msg` + `payload.type=="token_count"`:一次模型请求,token 取 `info.last_token_usage` 增量。
63
65
  */
64
66
  function summarizeRollout(content, start, end) {
65
67
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
66
- const summary = {
67
- userMessageCount: 0,
68
+ const result = {
69
+ turns: [],
68
70
  apiRequestCount: 0,
69
71
  inputTokens: 0,
70
72
  outputTokens: 0,
@@ -80,16 +82,19 @@ function summarizeRollout(content, start, end) {
80
82
  continue;
81
83
  }
82
84
  const payload = record.payload;
83
- if (payload.type === "user_message") {
84
- summary.userMessageCount += 1;
85
+ if (payload.type === "task_started" && typeof payload.turn_id === "string") {
86
+ const ms = new Date(getString(record.timestamp) ?? "").getTime();
87
+ if (Number.isFinite(ms)) {
88
+ result.turns.push({ turnId: payload.turn_id, ms });
89
+ }
85
90
  }
86
91
  if (payload.type === "token_count") {
87
92
  const usage = extractLastTokenUsage(payload);
88
93
  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);
94
+ result.apiRequestCount += 1;
95
+ result.inputTokens += getNumber(usage.input_tokens);
96
+ result.outputTokens += getNumber(usage.output_tokens);
97
+ result.cacheReadInputTokens += getNumber(usage.cached_input_tokens);
93
98
  }
94
99
  }
95
100
  }
@@ -97,48 +102,79 @@ function summarizeRollout(content, start, end) {
97
102
  continue;
98
103
  }
99
104
  }
100
- return summary;
105
+ return result;
101
106
  }
102
107
  /**
103
108
  * 从 Codex 本地 session rollout(<CODEX_HOME>/sessions 下递归的 .jsonl)统计消息数、请求数和 token 用量。
109
+ *
110
+ * 消息数 = task_started 的 distinct turn_id(跨文件去重)。重放副本会让同一 turn_id 出现在多个文件,
111
+ * 故用全局 Map<turn_id, minMs> 收集(取最早 timestamp = 真实发生时刻,早于任何重放副本),最后取 size。
104
112
  */
105
113
  async function summarizeCodexSessionUsage(start, end) {
106
114
  const codexDataDir = (0, paths_1.getCodexHome)();
107
115
  const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
108
116
  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
- };
117
+ const turnMinMs = new Map();
118
+ let apiRequestCount = 0;
119
+ let inputTokens = 0;
120
+ let outputTokens = 0;
121
+ let cacheReadInputTokens = 0;
118
122
  for (const filePath of files) {
119
123
  try {
120
124
  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;
125
+ const parsed = summarizeRollout(content, start, end);
126
+ for (const turn of parsed.turns) {
127
+ const prev = turnMinMs.get(turn.turnId);
128
+ if (prev === undefined || turn.ms < prev) {
129
+ turnMinMs.set(turn.turnId, turn.ms);
130
+ }
131
+ }
132
+ apiRequestCount += parsed.apiRequestCount;
133
+ inputTokens += parsed.inputTokens;
134
+ outputTokens += parsed.outputTokens;
135
+ cacheReadInputTokens += parsed.cacheReadInputTokens;
127
136
  }
128
137
  catch {
129
138
  continue;
130
139
  }
131
140
  }
132
- return totals;
141
+ return {
142
+ userMessageCount: turnMinMs.size,
143
+ apiRequestCount,
144
+ inputTokens,
145
+ outputTokens,
146
+ cacheReadInputTokens,
147
+ matchedFileCount: files.length,
148
+ codexDataDir,
149
+ };
133
150
  }
134
151
  /**
135
152
  * 按天汇总 Codex session rollout 中的消息数、请求数和 token 用量。
153
+ *
154
+ * 消息数同 weekly:先全局 Map<turn_id, minMs> 去重,再按 minMs 的本地日归桶(保证 weekly = Σ daily、
155
+ * 且重放副本跨天不重复)。token 维度按 token_count 事件 timestamp 的本地日累加。
136
156
  */
137
157
  async function summarizeCodexSessionUsageByDay(start, end) {
138
158
  const codexDataDir = (0, paths_1.getCodexHome)();
139
159
  const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
140
160
  const files = await collectRolloutFiles(sessionsDir);
161
+ const turnMinMs = new Map();
141
162
  const daily = new Map();
163
+ const ensureDay = (date) => {
164
+ let current = daily.get(date);
165
+ if (!current) {
166
+ current = {
167
+ date,
168
+ userMessageCount: 0,
169
+ apiRequestCount: 0,
170
+ inputTokens: 0,
171
+ outputTokens: 0,
172
+ cacheReadInputTokens: 0,
173
+ };
174
+ daily.set(date, current);
175
+ }
176
+ return current;
177
+ };
142
178
  for (const filePath of files) {
143
179
  let content = "";
144
180
  try {
@@ -162,36 +198,34 @@ async function summarizeCodexSessionUsageByDay(start, end) {
162
198
  continue;
163
199
  }
164
200
  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;
201
+ const ms = new Date(timestamp).getTime();
202
+ if (payload.type === "task_started" && typeof payload.turn_id === "string") {
203
+ if (Number.isFinite(ms)) {
204
+ const prev = turnMinMs.get(payload.turn_id);
205
+ if (prev === undefined || ms < prev) {
206
+ turnMinMs.set(payload.turn_id, ms);
207
+ }
208
+ }
169
209
  }
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;
210
+ if (payload.type === "token_count") {
211
+ const usage = extractLastTokenUsage(payload);
212
+ if (usage) {
213
+ const current = ensureDay((0, time_1.localDateKey)(new Date(ms)));
214
+ current.apiRequestCount += 1;
215
+ current.inputTokens += getNumber(usage.input_tokens);
216
+ current.outputTokens += getNumber(usage.output_tokens);
217
+ current.cacheReadInputTokens += getNumber(usage.cached_input_tokens);
218
+ }
181
219
  }
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
220
  }
190
221
  catch {
191
222
  continue;
192
223
  }
193
224
  }
194
225
  }
226
+ for (const ms of turnMinMs.values()) {
227
+ ensureDay((0, time_1.localDateKey)(new Date(ms))).userMessageCount += 1;
228
+ }
195
229
  return daily;
196
230
  }
197
231
  //# 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.4",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {