ccus-cli 0.1.26 → 0.2.3

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.
@@ -0,0 +1,197 @@
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.summarizeCodexSessionUsage = summarizeCodexSessionUsage;
7
+ exports.summarizeCodexSessionUsageByDay = summarizeCodexSessionUsageByDay;
8
+ const promises_1 = __importDefault(require("node:fs/promises"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const paths_1 = require("./paths");
11
+ const time_1 = require("./time");
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function getNumber(value) {
16
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
17
+ }
18
+ function getString(value) {
19
+ return typeof value === "string" && value.length > 0 ? value : null;
20
+ }
21
+ async function collectRolloutFiles(directoryPath) {
22
+ try {
23
+ const entries = await promises_1.default.readdir(directoryPath, { withFileTypes: true });
24
+ const nested = await Promise.all(entries.map(async (entry) => {
25
+ const fullPath = node_path_1.default.join(directoryPath, entry.name);
26
+ if (entry.isDirectory()) {
27
+ return collectRolloutFiles(fullPath);
28
+ }
29
+ return entry.isFile() && entry.name.endsWith(".jsonl") ? [fullPath] : [];
30
+ }));
31
+ return nested.flat();
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ENOENT") {
35
+ return [];
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ function timestampInRange(timestamp, start, end) {
41
+ if (!timestamp) {
42
+ return false;
43
+ }
44
+ const value = new Date(timestamp).getTime();
45
+ return Number.isFinite(value) && value >= start.getTime() && value <= end.getTime();
46
+ }
47
+ /**
48
+ * 从 `token_count` 事件的 `info.last_token_usage` 取 token 用量对象(缺失返回 null)。
49
+ * 必须用 last_token_usage(本次增量),不能用 total_token_usage(会话累计,会重复计)。
50
+ */
51
+ function extractLastTokenUsage(payload) {
52
+ if (!isRecord(payload.info)) {
53
+ return null;
54
+ }
55
+ return isRecord(payload.info.last_token_usage) ? payload.info.last_token_usage : null;
56
+ }
57
+ /**
58
+ * 统计单个 Codex rollout 文件内的 token / 消息。
59
+ *
60
+ * Codex rollout 每行一个事件,timestamp 在 top-level:
61
+ * - `event_msg` + `payload.type=="user_message"`:一次用户消息(工具结果是 `function_call_output`,不命中,无需过滤)。
62
+ * - `event_msg` + `payload.type=="token_count"`:一次模型请求,token 取 `info.last_token_usage` 增量。
63
+ */
64
+ function summarizeRollout(content, start, end) {
65
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
66
+ const summary = {
67
+ userMessageCount: 0,
68
+ apiRequestCount: 0,
69
+ inputTokens: 0,
70
+ outputTokens: 0,
71
+ cacheReadInputTokens: 0,
72
+ };
73
+ for (const line of lines) {
74
+ try {
75
+ const record = JSON.parse(line);
76
+ if (!isRecord(record) || !timestampInRange(getString(record.timestamp), start, end)) {
77
+ continue;
78
+ }
79
+ if (record.type !== "event_msg" || !isRecord(record.payload)) {
80
+ continue;
81
+ }
82
+ const payload = record.payload;
83
+ if (payload.type === "user_message") {
84
+ summary.userMessageCount += 1;
85
+ }
86
+ if (payload.type === "token_count") {
87
+ const usage = extractLastTokenUsage(payload);
88
+ 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);
93
+ }
94
+ }
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ }
100
+ return summary;
101
+ }
102
+ /**
103
+ * 从 Codex 本地 session rollout(<CODEX_HOME>/sessions 下递归的 .jsonl)统计消息数、请求数和 token 用量。
104
+ */
105
+ async function summarizeCodexSessionUsage(start, end) {
106
+ const codexDataDir = (0, paths_1.getCodexHome)();
107
+ const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
108
+ 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
+ };
118
+ for (const filePath of files) {
119
+ try {
120
+ 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;
127
+ }
128
+ catch {
129
+ continue;
130
+ }
131
+ }
132
+ return totals;
133
+ }
134
+ /**
135
+ * 按天汇总 Codex session rollout 中的消息数、请求数和 token 用量。
136
+ */
137
+ async function summarizeCodexSessionUsageByDay(start, end) {
138
+ const codexDataDir = (0, paths_1.getCodexHome)();
139
+ const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
140
+ const files = await collectRolloutFiles(sessionsDir);
141
+ const daily = new Map();
142
+ for (const filePath of files) {
143
+ let content = "";
144
+ try {
145
+ content = await promises_1.default.readFile(filePath, "utf8");
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
151
+ for (const line of lines) {
152
+ try {
153
+ const record = JSON.parse(line);
154
+ if (!isRecord(record)) {
155
+ continue;
156
+ }
157
+ const timestamp = getString(record.timestamp);
158
+ if (!timestampInRange(timestamp, start, end)) {
159
+ continue;
160
+ }
161
+ if (record.type !== "event_msg" || !isRecord(record.payload)) {
162
+ continue;
163
+ }
164
+ 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;
169
+ }
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;
181
+ }
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
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ }
194
+ }
195
+ return daily;
196
+ }
197
+ //# sourceMappingURL=codex-sessions.js.map
@@ -5,6 +5,7 @@ exports.bucketizeEvents = bucketizeEvents;
5
5
  exports.buildDashboardHtml = buildDashboardHtml;
6
6
  const aggregate_1 = require("./aggregate");
7
7
  const chart_assets_1 = require("./chart-assets");
8
+ const payload_1 = require("./payload");
8
9
  const time_1 = require("./time");
9
10
  /** 所有插入到 HTML/SVG 的动态文本都先转义,避免本地页面被注入内容。 */
10
11
  function escapeHtml(value) {
@@ -25,14 +26,14 @@ function average(values) {
25
26
  * 这里的 usage 指 Claude 的 5 小时额度使用率,而不是 context window 百分比。
26
27
  */
27
28
  function summarizeEvents(events) {
29
+ // 额度叠加 Codex(与 aggregate 同口径):peak 取所有读数 max、latest 按 source 分流后相加、7d 累计走合并曲线。
28
30
  const usages = events.map((event) => event.usagePct).filter((value) => value !== null);
29
31
  const sevenDayUsages = events.map((event) => event.sevenDayUsagePct).filter((value) => value !== null);
30
- const latestUsagePct = [...events]
31
- .sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime())
32
- .find((event) => event.usagePct !== null)?.usagePct ?? null;
33
- const latestSevenDayUsagePct = [...events]
34
- .sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime())
35
- .find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null;
32
+ const byDesc = (left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime();
33
+ const claudeByDesc = events.filter((event) => !(0, payload_1.isCodexSourceEvent)(event)).sort(byDesc);
34
+ const codexByDesc = events.filter(payload_1.isCodexSourceEvent).sort(byDesc);
35
+ const latestUsagePct = (0, aggregate_1.addNullable)(claudeByDesc.find((event) => event.usagePct !== null)?.usagePct ?? null, codexByDesc.find((event) => event.usagePct !== null)?.usagePct ?? null);
36
+ const latestSevenDayUsagePct = (0, aggregate_1.addNullable)(claudeByDesc.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null, codexByDesc.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null);
36
37
  // 7d 分区叠加累计:与 aggregate 同一套「去毛刺 + 分段峰谷和」,口径与团队看板一致。
37
38
  const sevenDayCumulativeUsagePct = (0, aggregate_1.computeCumulativeSevenDay)((0, aggregate_1.buildSevenDayCurveFromEvents)(events));
38
39
  return {
@@ -170,10 +171,12 @@ function renderDailyMessages(points) {
170
171
  return "";
171
172
  }
172
173
  const total = points.reduce((sum, point) => sum + point.userMessageCount, 0);
173
- // 离散「天」用 category x(索引 0..n-1 + 月-日标签),单系列纵向柱;uPlot.paths.bars
174
+ const codexTotal = points.reduce((sum, point) => sum + (point.codexUserMessageCount ?? 0), 0);
175
+ // 离散「天」用 category x(索引 0..n-1 + 月-日标签),双系列纵向柱(Claude / Codex);uPlot.paths.bars
174
176
  // 画柱、draw-hook 在柱顶标数值,cursor 悬停某天在 legend 显示当日计数。
175
177
  const xs = points.map((_, index) => index);
176
178
  const counts = points.map((point) => point.userMessageCount);
179
+ const codexCounts = points.map((point) => point.codexUserMessageCount ?? 0);
177
180
  const xLabels = points.map((point) => point.date.slice(5));
178
181
  const spec = {
179
182
  height: 280,
@@ -181,12 +184,18 @@ function renderDailyMessages(points) {
181
184
  xLabels,
182
185
  yMin0: true,
183
186
  bars: true,
184
- series: [{ label: "用户消息数", stroke: "#5eead4", fill: "rgba(94, 234, 212, 0.55)", width: 1 }],
185
- legendGroups: [{ label: "用户消息数", color: "#5eead4", seriesIdx: [1] }],
187
+ series: [
188
+ { label: "Claude 消息数", stroke: "#5eead4", fill: "rgba(94, 234, 212, 0.55)", width: 1 },
189
+ { label: "Codex 消息数", stroke: "#f59e0b", fill: "rgba(245, 158, 11, 0.55)", width: 1 },
190
+ ],
191
+ legendGroups: [
192
+ { label: "Claude 消息数", color: "#5eead4", seriesIdx: [1] },
193
+ { label: "Codex 消息数", color: "#f59e0b", seriesIdx: [2] },
194
+ ],
186
195
  };
187
- const chart = (0, chart_assets_1.renderUplotChart)("chart-daily-messages", spec, [xs, counts]);
188
- const noData = total === 0
189
- ? `<div class="empty-state">当前时间窗口里还没有统计到 Claude 用户消息。</div>`
196
+ const chart = (0, chart_assets_1.renderUplotChart)("chart-daily-messages", spec, [xs, counts, codexCounts]);
197
+ const noData = total === 0 && codexTotal === 0
198
+ ? `<div class="empty-state">当前时间窗口里还没有统计到 Claude / Codex 用户消息。</div>`
190
199
  : "";
191
200
  return `
192
201
  <section class="panel chart-panel">
@@ -195,7 +204,7 @@ function renderDailyMessages(points) {
195
204
  <p class="eyebrow">Daily Messages</p>
196
205
  <h2>每日用户消息数</h2>
197
206
  </div>
198
- <p class="muted">按自然日统计的真实用户请求数(口径同导出 userMessageCount),共 ${total} 条</p>
207
+ <p class="muted">按自然日统计的真实用户请求数(口径同导出 userMessageCount),共 ${total} 条 Claude · ${codexTotal} 条 Codex</p>
199
208
  </div>
200
209
  ${noData}
201
210
  ${chart}
@@ -257,6 +266,8 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
257
266
  const summary = summarizeEvents(events);
258
267
  const buckets = bucketizeEvents(events, start, end, pickBucketMinutes(start, end));
259
268
  const totalUserMessages = dailyUserMessages.reduce((sum, point) => sum + point.userMessageCount, 0);
269
+ const codexTotalUserMessages = dailyUserMessages.reduce((sum, point) => sum + (point.codexUserMessageCount ?? 0), 0);
270
+ const combinedUserMessages = totalUserMessages + codexTotalUserMessages;
260
271
  return `<!doctype html>
261
272
  <html lang="zh-CN">
262
273
  <head>
@@ -449,8 +460,8 @@ function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages =
449
460
  </article>
450
461
  <article class="panel stat-card">
451
462
  <h2>用户消息数</h2>
452
- <p class="stat-value">${totalUserMessages}</p>
453
- <p class="stat-note">窗口内每日真实用户请求数合计</p>
463
+ <p class="stat-value">${combinedUserMessages}</p>
464
+ <p class="stat-note">窗口内每日真实用户请求数合计(Claude ${totalUserMessages} · Codex ${codexTotalUserMessages})</p>
454
465
  </article>
455
466
  </section>
456
467
  ${renderChart(buckets)}
@@ -98,6 +98,7 @@ function buildAggregatedDetailCsv(events) {
98
98
  "sessionId",
99
99
  "workspaceName",
100
100
  "modelName",
101
+ "source",
101
102
  "fiveHourUsagePct",
102
103
  "contextWindowPct",
103
104
  "contextUsedM",
@@ -114,6 +115,7 @@ function buildAggregatedDetailCsv(events) {
114
115
  event.sessionId,
115
116
  event.workspaceName,
116
117
  event.modelName,
118
+ event.source,
117
119
  event.usagePct,
118
120
  event.contextWindowPct,
119
121
  toMillions(event.contextUsed),
package/dist/lib/paths.js CHANGED
@@ -11,6 +11,10 @@ exports.getSyncConfigPath = getSyncConfigPath;
11
11
  exports.getSyncStatePath = getSyncStatePath;
12
12
  exports.getApiConfigPath = getApiConfigPath;
13
13
  exports.getApiQuotaCachePath = getApiQuotaCachePath;
14
+ exports.getCodexQuotaCachePath = getCodexQuotaCachePath;
15
+ exports.getCodexHome = getCodexHome;
16
+ exports.getCodexConfigPath = getCodexConfigPath;
17
+ exports.getCodexHooksPath = getCodexHooksPath;
14
18
  exports.getClaudeDataDir = getClaudeDataDir;
15
19
  exports.getClaudeSettingsPath = getClaudeSettingsPath;
16
20
  const node_os_1 = __importDefault(require("node:os"));
@@ -66,6 +70,22 @@ function getApiConfigPath(dataDir) {
66
70
  function getApiQuotaCachePath(dataDir) {
67
71
  return node_path_1.default.join(dataDir, "api-quota-cache.json");
68
72
  }
73
+ /** Codex 额度缓存文件,记录上次 app-server 拉取的额度与时间。 */
74
+ function getCodexQuotaCachePath(dataDir) {
75
+ return node_path_1.default.join(dataDir, "codex-quota-cache.json");
76
+ }
77
+ /** Codex CLI 默认本地数据目录(`CODEX_HOME` 环境变量可覆盖,默认 `~/.codex`)。 */
78
+ function getCodexHome() {
79
+ return process.env.CODEX_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".codex");
80
+ }
81
+ /** Codex CLI 用户级 config.toml 路径,`ccus install --codex` 往里写 notify。 */
82
+ function getCodexConfigPath() {
83
+ return node_path_1.default.join(getCodexHome(), "config.toml");
84
+ }
85
+ /** Codex CLI 用户级 hooks.json 路径,`ccus install --codex` 往 Stop 事件挂 hook(orca 等 hook-only 环境用)。 */
86
+ function getCodexHooksPath() {
87
+ return node_path_1.default.join(getCodexHome(), "hooks.json");
88
+ }
69
89
  /** Claude Code 默认本地数据目录。 */
70
90
  function getClaudeDataDir() {
71
91
  const configured = process.env.CCUS_CLAUDE_DATA_DIR;
@@ -10,6 +10,8 @@ exports.resolveCtxTier = resolveCtxTier;
10
10
  exports.resolveCtxRedThresholds = resolveCtxRedThresholds;
11
11
  exports.formatStatusLine = formatStatusLine;
12
12
  exports.createPersistedStatuslineEvent = createPersistedStatuslineEvent;
13
+ exports.isCodexSourceRecord = isCodexSourceRecord;
14
+ exports.isCodexSourceEvent = isCodexSourceEvent;
13
15
  exports.computeStatuslineEvent = computeStatuslineEvent;
14
16
  const node_path_1 = __importDefault(require("node:path"));
15
17
  const time_1 = require("./time");
@@ -271,6 +273,14 @@ function createPersistedStatuslineEvent(payload, now = new Date()) {
271
273
  rawPayload: payload,
272
274
  };
273
275
  }
276
+ /** 判断事件是否来自 Codex(rawPayload.source === "codex");Claude statusline 事件无该字段,视为 claude。 */
277
+ function isCodexSourceRecord(record) {
278
+ return record.rawPayload?.source === "codex";
279
+ }
280
+ /** StatuslineEvent 视图版的 Codex 来源判断(event.rawPayload 透传自 record)。 */
281
+ function isCodexSourceEvent(event) {
282
+ return event.rawPayload?.source === "codex";
283
+ }
274
284
  /**
275
285
  * 从持久化事件计算出 dashboard/export/statusline 使用的完整视图。
276
286
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.1.26",
3
+ "version": "0.2.3",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -15,8 +15,8 @@
15
15
  "scripts": {
16
16
  "build": "tsc -p tsconfig.json && node scripts/copy-vendor.js",
17
17
  "prepublishOnly": "npm run build",
18
- "test": "node --test dist/test",
19
- "test:src": "node --import tsx --test src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.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",
18
+ "test": "node --test --test-concurrency 1 \"dist/test/**/*.test.js\"",
19
+ "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/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/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
20
  "smoke:api": "node scripts/smoke-api.mjs"
21
21
  },
22
22
  "keywords": [