codebuddy-stats 1.3.2 → 1.3.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.
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import blessed from 'blessed';
6
+ import { performance } from 'node:perf_hooks';
6
7
  import { loadUsageData } from './lib/data-loader.js';
7
8
  import { resolveProjectName } from './lib/workspace-resolver.js';
8
9
  import { compareByCostThenTokens, formatCost, formatNumber, formatPercent, formatTokens, truncate } from './lib/utils.js';
@@ -72,7 +73,7 @@ function printTextReport(data) {
72
73
  console.log(`\nTop model: ${topModel.id} (${formatCost(topModel.cost)})`);
73
74
  }
74
75
  if (topProject) {
75
- const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
76
+ const shortName = data.projectDisplayNames?.[topProject.name] ?? resolveProjectName(topProject.name, data.workspaceMappings);
76
77
  console.log(`Top project: ${shortName}`);
77
78
  console.log(` (${formatCost(topProject.cost)})`);
78
79
  }
@@ -86,7 +87,7 @@ function printTextReport(data) {
86
87
  for (const [project, stats] of Object.entries(projectTotals)
87
88
  .sort((a, b) => b[1].cost - a[1].cost)
88
89
  .slice(0, 10)) {
89
- const shortName = resolveProjectName(project, data.workspaceMappings);
90
+ const shortName = data.projectDisplayNames?.[project] ?? resolveProjectName(project, data.workspaceMappings);
90
91
  console.log(` ${truncate(shortName, 40)}: ${formatCost(stats.cost)}`); // eslint-disable-line no-console
91
92
  }
92
93
  console.log('\n' + '='.repeat(50) + '\n');
@@ -185,8 +186,14 @@ async function main() {
185
186
  content += ' {gray-fg}(Tab view, s source){/gray-fg}';
186
187
  tabBar.setContent(content);
187
188
  }
189
+ // profiling(默认关闭;建议配合 --no-tui 使用以免打乱终端 UI)
190
+ const prof = process.env.CBS_PROFILE === '1';
191
+ const profTui = process.env.CBS_PROFILE_TUI === '1';
192
+ let lastRenderMs = 0;
193
+ let lastRenderTab = '';
188
194
  // 更新内容
189
195
  function updateContent() {
196
+ const t0 = prof ? performance.now() : 0;
190
197
  const width = Number(screen.width) || 80;
191
198
  const note = state.currentSource === 'code'
192
199
  ? `针对 CodeBuddy Code < 2.20.0 版本产生的数据,由于没有请求级别的 model ID,用量是基于当前 CodeBuddy Code 设置的 model ID(${state.data.defaultModelId})计算价格的`
@@ -225,7 +232,7 @@ async function main() {
225
232
  const dailyMaxOffset = Math.max(0, Object.keys(state.data.dailySummary).length - state.dailyPageSize);
226
233
  state.dailyScrollOffset = Math.min(state.dailyScrollOffset, dailyMaxOffset);
227
234
  state.dailySelectedIndex = Math.min(state.dailySelectedIndex, Math.max(0, Object.keys(state.data.dailySummary).length - 1));
228
- const sortedMonths = getSortedMonthsFromDailyData(state.data.dailyData);
235
+ const sortedMonths = state.data.sortedMonths ?? getSortedMonthsFromDailyData(state.data.dailyData);
229
236
  // 如果当前在 Monthly detail 但该月份已不存在,回到列表
230
237
  if (state.monthlyDetailMonth && !sortedMonths.includes(state.monthlyDetailMonth)) {
231
238
  state.monthlyDetailMonth = null;
@@ -243,27 +250,39 @@ async function main() {
243
250
  }
244
251
  // Monthly detail 的滚动上限
245
252
  if (state.monthlyDetailMonth) {
246
- const projects = new Set();
247
- for (const [date, dayData] of Object.entries(state.data.dailyData)) {
248
- if (!date.startsWith(state.monthlyDetailMonth))
249
- continue;
250
- for (const projectName of Object.keys(dayData ?? {}))
251
- projects.add(projectName);
253
+ const cachedMonthProjects = state.data.monthlyByProject?.[state.monthlyDetailMonth];
254
+ let projectCount = 0;
255
+ if (cachedMonthProjects) {
256
+ projectCount = Object.keys(cachedMonthProjects).length;
252
257
  }
253
- const maxOffset = Math.max(0, projects.size - state.monthlyDetailPageSize);
258
+ else {
259
+ const projects = new Set();
260
+ for (const [date, dayData] of Object.entries(state.data.dailyData)) {
261
+ if (!date.startsWith(state.monthlyDetailMonth))
262
+ continue;
263
+ for (const projectName of Object.keys(dayData ?? {}))
264
+ projects.add(projectName);
265
+ }
266
+ projectCount = projects.size;
267
+ }
268
+ const maxOffset = Math.max(0, projectCount - state.monthlyDetailPageSize);
254
269
  state.monthlyDetailScrollOffset = Math.min(state.monthlyDetailScrollOffset, maxOffset);
255
270
  }
256
271
  switch (state.currentTab) {
257
272
  case 0:
273
+ lastRenderTab = 'overview';
258
274
  renderOverview(contentBox, state.data, width, innerHeight, note);
259
275
  break;
260
276
  case 1:
277
+ lastRenderTab = 'by-model';
261
278
  renderByModel(contentBox, state.data, state.modelScrollOffset, width, note, state.modelPageSize);
262
279
  break;
263
280
  case 2:
281
+ lastRenderTab = 'by-project';
264
282
  renderByProject(contentBox, state.data, state.projectScrollOffset, width, note, state.projectPageSize);
265
283
  break;
266
284
  case 3:
285
+ lastRenderTab = state.dailyDetailDate ? 'daily-detail' : 'daily';
267
286
  if (state.dailyDetailDate) {
268
287
  renderDailyDetail(contentBox, state.data, state.dailyDetailDate, state.dailyDetailScrollOffset, width, state.dailyDetailPageSize);
269
288
  }
@@ -272,6 +291,7 @@ async function main() {
272
291
  }
273
292
  break;
274
293
  case 4:
294
+ lastRenderTab = state.monthlyDetailMonth ? 'monthly-detail' : 'monthly';
275
295
  if (state.monthlyDetailMonth) {
276
296
  renderMonthlyDetail(contentBox, state.data, state.monthlyDetailMonth, state.monthlyDetailScrollOffset, width, state.monthlyDetailPageSize);
277
297
  }
@@ -280,6 +300,12 @@ async function main() {
280
300
  }
281
301
  break;
282
302
  }
303
+ if (prof) {
304
+ lastRenderMs = performance.now() - t0;
305
+ if (profTui) {
306
+ console.error(`[cbs] render tab=${lastRenderTab} ${lastRenderMs.toFixed(1)}ms`); // eslint-disable-line no-console
307
+ }
308
+ }
283
309
  }
284
310
  // 更新状态栏
285
311
  function updateStatusBar() {
@@ -291,9 +317,10 @@ async function main() {
291
317
  const reservedForRight = rightContent.length + 2; // 版本号 + 两侧空格
292
318
  const availableForLeft = width - reservedForRight;
293
319
  let leftContent;
294
- const fullContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(state.data.grandTotal.cost)} | q quit, Tab view, s source, r refresh`;
295
- const mediumContent = ` ${daysInfo} | ${sourceInfo} | ${formatCost(state.data.grandTotal.cost)} | q/Tab/s/r`;
296
- const shortContent = ` ${sourceInfo} | ${formatCost(state.data.grandTotal.cost)} | q/Tab/s/r`;
320
+ const perfInfo = prof ? ` | render:${lastRenderTab} ${Math.round(lastRenderMs)}ms` : '';
321
+ const fullContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(state.data.grandTotal.cost)}${perfInfo} | q quit, Tab view, s source, r refresh`;
322
+ const mediumContent = ` ${daysInfo} | ${sourceInfo} | ${formatCost(state.data.grandTotal.cost)}${perfInfo} | q/Tab/s/r`;
323
+ const shortContent = ` ${sourceInfo} | ${formatCost(state.data.grandTotal.cost)}${perfInfo} | q/Tab/s/r`;
297
324
  const minContent = ` ${formatCost(state.data.grandTotal.cost)}`;
298
325
  if (fullContent.length <= availableForLeft) {
299
326
  leftContent = fullContent;
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { parentPort, workerData } from 'node:worker_threads';
6
+ import { DEFAULT_MODEL_ID, getPricingForModel, tokensToCost } from './pricing.js';
7
+ function getProjectName(filePath) {
8
+ const parts = filePath.split(path.sep);
9
+ const projectsIndex = parts.lastIndexOf('projects');
10
+ if (projectsIndex !== -1 && projectsIndex < parts.length - 1) {
11
+ return parts[projectsIndex + 1] ?? 'unknown-project';
12
+ }
13
+ return 'unknown-project';
14
+ }
15
+ function extractUsageStats(usage) {
16
+ const promptTokens = usage.prompt_tokens ?? 0;
17
+ const completionTokens = usage.completion_tokens ?? 0;
18
+ const totalTokens = usage.total_tokens ?? promptTokens + completionTokens;
19
+ const cacheHitTokens = usage.prompt_cache_hit_tokens ?? 0;
20
+ const cacheMissTokens = usage.prompt_cache_miss_tokens ?? (cacheHitTokens > 0 ? Math.max(promptTokens - cacheHitTokens, 0) : 0);
21
+ const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
22
+ return {
23
+ promptTokens,
24
+ completionTokens,
25
+ totalTokens,
26
+ cacheHitTokens,
27
+ cacheMissTokens,
28
+ cacheWriteTokens,
29
+ };
30
+ }
31
+ function computeUsageCost(usage, modelId) {
32
+ const stats = extractUsageStats(usage);
33
+ const pricing = getPricingForModel(modelId);
34
+ let inputCost = 0;
35
+ if (stats.cacheHitTokens || stats.cacheMissTokens || stats.cacheWriteTokens) {
36
+ inputCost += tokensToCost(stats.cacheHitTokens, pricing.cacheRead);
37
+ inputCost += tokensToCost(stats.cacheMissTokens, pricing.prompt);
38
+ inputCost += tokensToCost(stats.cacheWriteTokens, pricing.cacheWrite);
39
+ }
40
+ else {
41
+ inputCost += tokensToCost(stats.promptTokens, pricing.prompt);
42
+ }
43
+ const outputCost = tokensToCost(stats.completionTokens, pricing.completion);
44
+ return { cost: inputCost + outputCost, stats };
45
+ }
46
+ function ensureDailyModelStats(dailyData, date, project, modelId) {
47
+ dailyData[date] ??= {};
48
+ dailyData[date][project] ??= {};
49
+ dailyData[date][project][modelId] ??= {
50
+ cost: 0,
51
+ promptTokens: 0,
52
+ completionTokens: 0,
53
+ totalTokens: 0,
54
+ cacheHitTokens: 0,
55
+ cacheMissTokens: 0,
56
+ cacheWriteTokens: 0,
57
+ requests: 0,
58
+ };
59
+ return dailyData[date][project][modelId];
60
+ }
61
+ async function main() {
62
+ const input = workerData;
63
+ const files = Array.isArray(input?.files) ? input.files : [];
64
+ const defaultModelId = typeof input?.defaultModelId === 'string' && input.defaultModelId ? input.defaultModelId : DEFAULT_MODEL_ID;
65
+ const minDate = typeof input?.minDate === 'string' ? input.minDate : null;
66
+ const dailyData = {};
67
+ const modelTotals = {};
68
+ const projectTotals = {};
69
+ const grandTotal = { cost: 0, tokens: 0, requests: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
70
+ for (const filePath of files) {
71
+ const projectName = getProjectName(filePath);
72
+ let fileStream;
73
+ try {
74
+ fileStream = fsSync.createReadStream(filePath);
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ const rl = createInterface({ input: fileStream, crlfDelay: Number.POSITIVE_INFINITY });
80
+ for await (const line of rl) {
81
+ try {
82
+ const record = JSON.parse(line);
83
+ const usage = record?.providerData?.rawUsage;
84
+ const timestamp = record?.timestamp;
85
+ if (!usage || timestamp == null)
86
+ continue;
87
+ const dateObj = new Date(timestamp);
88
+ if (Number.isNaN(dateObj.getTime()))
89
+ continue;
90
+ const date = dateObj.toISOString().split('T')[0];
91
+ if (!date)
92
+ continue;
93
+ if (minDate && date < minDate)
94
+ continue;
95
+ const recordModelId = record?.providerData?.model;
96
+ const modelFromRecord = typeof recordModelId === 'string' ? recordModelId : null;
97
+ const usedModelId = modelFromRecord || defaultModelId;
98
+ const { cost, stats: usageStats } = computeUsageCost(usage, usedModelId);
99
+ const dayStats = ensureDailyModelStats(dailyData, date, projectName, usedModelId);
100
+ dayStats.cost += cost;
101
+ dayStats.promptTokens += usageStats.promptTokens;
102
+ dayStats.completionTokens += usageStats.completionTokens;
103
+ dayStats.totalTokens += usageStats.totalTokens;
104
+ dayStats.cacheHitTokens += usageStats.cacheHitTokens;
105
+ dayStats.cacheMissTokens += usageStats.cacheMissTokens;
106
+ dayStats.cacheWriteTokens += usageStats.cacheWriteTokens;
107
+ dayStats.requests += 1;
108
+ modelTotals[usedModelId] ??= { cost: 0, tokens: 0, requests: 0 };
109
+ modelTotals[usedModelId].cost += cost;
110
+ modelTotals[usedModelId].tokens += usageStats.totalTokens;
111
+ modelTotals[usedModelId].requests += 1;
112
+ projectTotals[projectName] ??= { cost: 0, tokens: 0, requests: 0 };
113
+ projectTotals[projectName].cost += cost;
114
+ projectTotals[projectName].tokens += usageStats.totalTokens;
115
+ projectTotals[projectName].requests += 1;
116
+ grandTotal.cost += cost;
117
+ grandTotal.tokens += usageStats.totalTokens;
118
+ grandTotal.requests += 1;
119
+ grandTotal.cacheHitTokens += usageStats.cacheHitTokens;
120
+ grandTotal.cacheMissTokens += usageStats.cacheMissTokens;
121
+ }
122
+ catch {
123
+ // ignore
124
+ }
125
+ }
126
+ }
127
+ parentPort?.postMessage({ dailyData, modelTotals, projectTotals, grandTotal });
128
+ }
129
+ main().catch(err => {
130
+ // 让主线程感知 worker 失败
131
+ throw err;
132
+ });
@@ -1,12 +1,33 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import fsSync from 'node:fs';
3
3
  import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { createHash } from 'node:crypto';
6
+ import { performance } from 'node:perf_hooks';
4
7
  import { createInterface } from 'node:readline';
8
+ import { Worker } from 'node:worker_threads';
5
9
  import { getIdeDataDir, getProjectsDir, getSettingsPath } from './paths.js';
6
10
  import { DEFAULT_MODEL_ID, getPricingForModel, tokensToCost } from './pricing.js';
7
11
  import { compareByCostThenTokens } from './utils.js';
8
- import { loadWorkspaceMappings } from './workspace-resolver.js';
12
+ import { loadWorkspaceMappings, resolveProjectName } from './workspace-resolver.js';
9
13
  export const BASE_DIR = getProjectsDir();
14
+ const memoryCache = new Map();
15
+ function getCacheKey(source, days) {
16
+ return `${source}:${days ?? 'all'}`;
17
+ }
18
+ function cacheGet(source, days) {
19
+ return memoryCache.get(getCacheKey(source, days));
20
+ }
21
+ function cacheSet(source, days, entry) {
22
+ const key = getCacheKey(source, days);
23
+ memoryCache.set(key, entry);
24
+ // 简单上限:只保留最近 4 个 key
25
+ if (memoryCache.size > 4) {
26
+ const firstKey = memoryCache.keys().next().value;
27
+ if (firstKey)
28
+ memoryCache.delete(firstKey);
29
+ }
30
+ }
10
31
  async function loadModelFromSettings() {
11
32
  try {
12
33
  const settingsPath = getSettingsPath();
@@ -116,24 +137,159 @@ function ensureDailyModelStats(dailyData, date, project, modelId) {
116
137
  };
117
138
  return dailyData[date][project][modelId];
118
139
  }
140
+ function mergeSummaryTotals(target, src) {
141
+ for (const [k, v] of Object.entries(src)) {
142
+ target[k] ??= { cost: 0, tokens: 0, requests: 0 };
143
+ target[k].cost += v.cost;
144
+ target[k].tokens += v.tokens;
145
+ target[k].requests += v.requests;
146
+ }
147
+ }
148
+ function mergeGrandTotal(target, src) {
149
+ target.cost += src.cost;
150
+ target.tokens += src.tokens;
151
+ target.requests += src.requests;
152
+ target.cacheHitTokens += src.cacheHitTokens;
153
+ target.cacheMissTokens += src.cacheMissTokens;
154
+ }
155
+ function mergeDailyData(target, src) {
156
+ for (const [date, byProject] of Object.entries(src)) {
157
+ target[date] ??= {};
158
+ for (const [project, byModel] of Object.entries(byProject ?? {})) {
159
+ target[date][project] ??= {};
160
+ for (const [modelId, s] of Object.entries(byModel ?? {})) {
161
+ const t = ensureDailyModelStats(target, date, project, modelId);
162
+ t.cost += s.cost;
163
+ t.promptTokens += s.promptTokens;
164
+ t.completionTokens += s.completionTokens;
165
+ t.totalTokens += s.totalTokens;
166
+ t.cacheHitTokens += s.cacheHitTokens;
167
+ t.cacheMissTokens += s.cacheMissTokens;
168
+ t.cacheWriteTokens += s.cacheWriteTokens;
169
+ t.requests += s.requests;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ function chunkArray(arr, chunks) {
175
+ const n = Math.max(1, Math.floor(chunks));
176
+ const out = Array.from({ length: n }, () => []);
177
+ for (let i = 0; i < arr.length; i++)
178
+ out[i % n].push(arr[i]);
179
+ return out.filter(a => a.length > 0);
180
+ }
181
+ async function parseCodeUsageWithWorkers(files, defaultModelId, minDate, workerCount) {
182
+ const chunks = chunkArray(files, Math.min(workerCount, files.length));
183
+ const workerUrl = new URL('./code-usage.worker.js', import.meta.url);
184
+ const results = await Promise.all(chunks.map(chunk => new Promise((resolve, reject) => {
185
+ const worker = new Worker(workerUrl, {
186
+ workerData: {
187
+ files: chunk,
188
+ defaultModelId,
189
+ minDate,
190
+ },
191
+ });
192
+ worker.once('message', msg => resolve(msg));
193
+ worker.once('error', err => reject(err));
194
+ worker.once('exit', code => {
195
+ if (code !== 0)
196
+ reject(new Error(`worker exited with code ${code}`));
197
+ });
198
+ })));
199
+ const merged = {
200
+ dailyData: {},
201
+ modelTotals: {},
202
+ projectTotals: {},
203
+ grandTotal: { cost: 0, tokens: 0, requests: 0, cacheHitTokens: 0, cacheMissTokens: 0 },
204
+ };
205
+ for (const r of results) {
206
+ mergeDailyData(merged.dailyData, r.dailyData);
207
+ mergeSummaryTotals(merged.modelTotals, r.modelTotals);
208
+ mergeSummaryTotals(merged.projectTotals, r.projectTotals);
209
+ mergeGrandTotal(merged.grandTotal, r.grandTotal);
210
+ }
211
+ return merged;
212
+ }
119
213
  function finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal, workspaceMappings) {
214
+ const prof = process.env.CBS_PROFILE === '1';
215
+ const profLog = prof && (process.env.CBS_PROFILE_TUI === '1' || process.argv.includes('--no-tui'));
216
+ const t0 = prof ? performance.now() : 0;
120
217
  const dailySummary = {};
121
- for (const date of Object.keys(dailyData)) {
122
- dailySummary[date] = { cost: 0, tokens: 0, requests: 0 };
123
- for (const project of Object.values(dailyData[date] ?? {})) {
124
- for (const model of Object.values(project ?? {})) {
125
- dailySummary[date].cost += model.cost;
126
- dailySummary[date].tokens += model.totalTokens;
127
- dailySummary[date].requests += model.requests;
218
+ const dailyTops = {};
219
+ const monthlyAgg = {};
220
+ const monthlyByProject = {};
221
+ // 一次性遍历 dailyData:生成 dailySummary + dailyTops + monthly 聚合
222
+ for (const [date, dayData] of Object.entries(dailyData)) {
223
+ const daySummary = { cost: 0, tokens: 0, requests: 0 };
224
+ let topModelId = '-';
225
+ let topModelCost = 0;
226
+ let topProjectName = '-';
227
+ let topProjectCost = 0;
228
+ const month = date.slice(0, 7);
229
+ if (month) {
230
+ monthlyAgg[month] ??= {
231
+ cost: 0,
232
+ tokens: 0,
233
+ requests: 0,
234
+ cacheHitTokens: 0,
235
+ cacheMissTokens: 0,
236
+ modelCost: {},
237
+ projectCost: {},
238
+ };
239
+ monthlyByProject[month] ??= {};
240
+ }
241
+ for (const [projectName, models] of Object.entries(dayData ?? {})) {
242
+ let projectCost = 0;
243
+ if (month) {
244
+ monthlyByProject[month][projectName] ??= { cost: 0, tokens: 0, requests: 0, modelCost: {} };
245
+ }
246
+ for (const [modelId, stats] of Object.entries(models ?? {})) {
247
+ const cost = stats.cost;
248
+ const tokens = stats.totalTokens;
249
+ const requests = stats.requests;
250
+ daySummary.cost += cost;
251
+ daySummary.tokens += tokens;
252
+ daySummary.requests += requests;
253
+ projectCost += cost;
254
+ if (cost > topModelCost) {
255
+ topModelCost = cost;
256
+ topModelId = modelId;
257
+ }
258
+ if (month) {
259
+ const mAgg = monthlyAgg[month];
260
+ mAgg.cost += cost;
261
+ mAgg.tokens += tokens;
262
+ mAgg.requests += requests;
263
+ mAgg.cacheHitTokens += stats.cacheHitTokens;
264
+ mAgg.cacheMissTokens += stats.cacheMissTokens;
265
+ mAgg.modelCost[modelId] = (mAgg.modelCost[modelId] ?? 0) + cost;
266
+ mAgg.projectCost[projectName] = (mAgg.projectCost[projectName] ?? 0) + cost;
267
+ const pAgg = monthlyByProject[month][projectName];
268
+ pAgg.cost += cost;
269
+ pAgg.tokens += tokens;
270
+ pAgg.requests += requests;
271
+ pAgg.modelCost[modelId] = (pAgg.modelCost[modelId] ?? 0) + cost;
272
+ }
273
+ }
274
+ if (projectCost > topProjectCost) {
275
+ topProjectCost = projectCost;
276
+ topProjectName = projectName;
128
277
  }
129
278
  }
279
+ dailySummary[date] = daySummary;
280
+ dailyTops[date] = { topModelId, topModelCost, topProjectName, topProjectCost };
130
281
  }
282
+ const projectDisplayNames = {};
283
+ for (const projectName of Object.keys(projectTotals)) {
284
+ projectDisplayNames[projectName] = resolveProjectName(projectName, workspaceMappings);
285
+ }
286
+ const sortedMonths = Object.keys(monthlyAgg).sort().reverse();
131
287
  const topModelEntry = Object.entries(modelTotals).sort(compareByCostThenTokens)[0];
132
288
  const topProjectEntry = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost)[0];
133
289
  const cacheHitRate = grandTotal.cacheHitTokens + grandTotal.cacheMissTokens > 0
134
290
  ? grandTotal.cacheHitTokens / (grandTotal.cacheHitTokens + grandTotal.cacheMissTokens)
135
291
  : 0;
136
- return {
292
+ const out = {
137
293
  defaultModelId,
138
294
  dailyData,
139
295
  dailySummary,
@@ -145,22 +301,94 @@ function finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals,
145
301
  cacheHitRate,
146
302
  activeDays: Object.keys(dailyData).length,
147
303
  workspaceMappings,
304
+ projectDisplayNames,
305
+ dailyTops,
306
+ monthlyAgg,
307
+ monthlyByProject,
308
+ sortedMonths,
148
309
  };
310
+ if (profLog) {
311
+ const ms = performance.now() - t0;
312
+ console.error(`[cbs] finalizeAnalysis ${ms.toFixed(1)}ms (days=${Object.keys(dailyData).length}, projects=${Object.keys(projectTotals).length}, models=${Object.keys(modelTotals).length})`); // eslint-disable-line no-console
313
+ }
314
+ return out;
149
315
  }
150
316
  /**
151
317
  * 加载所有用量数据
152
318
  */
153
319
  export async function loadUsageData(options = {}) {
154
320
  const source = options.source ?? 'code';
155
- if (source === 'ide') {
156
- return loadIdeUsageData(options);
321
+ const prof = process.env.CBS_PROFILE === '1';
322
+ const profLog = prof && (process.env.CBS_PROFILE_TUI === '1' || process.argv.includes('--no-tui'));
323
+ const t0 = prof ? performance.now() : 0;
324
+ const data = source === 'ide' ? await loadIdeUsageData(options) : await loadCodeUsageData(options);
325
+ if (profLog) {
326
+ const ms = performance.now() - t0;
327
+ const daysInfo = options.days ? `days=${options.days}` : 'days=all';
328
+ // 用 stderr 避免影响 TUI 输出(建议配合 --no-tui 使用)
329
+ console.error(`[cbs] loadUsageData source=${source} ${daysInfo} ${ms.toFixed(1)}ms`); // eslint-disable-line no-console
157
330
  }
158
- return loadCodeUsageData(options);
331
+ return data;
159
332
  }
160
333
  async function loadCodeUsageData(options = {}) {
334
+ const prof = process.env.CBS_PROFILE === '1';
335
+ const profLog = prof && (process.env.CBS_PROFILE_TUI === '1' || process.argv.includes('--no-tui'));
336
+ const t0 = prof ? performance.now() : 0;
337
+ const workerCountEnv = Number.parseInt(process.env.CBS_WORKERS ?? '', 10);
338
+ const workerCount = Number.isFinite(workerCountEnv)
339
+ ? Math.max(1, workerCountEnv)
340
+ : Math.min(4, Math.max(1, os.cpus().length - 1));
341
+ const useWorkersDefault = workerCount > 1;
342
+ const useWorkers = useWorkersDefault &&
343
+ // tsx/dev 场景下没有编译后的 worker 文件,自动回退
344
+ !import.meta.url.endsWith('.ts');
161
345
  const defaultModelId = await loadModelFromSettings();
162
346
  const jsonlFiles = await findJsonlFiles(BASE_DIR);
163
347
  const minDate = computeMinDate(options.days);
348
+ // 同进程内存缓存(不落盘):refresh 时如果源文件未变,直接复用结果
349
+ const sortedFiles = [...jsonlFiles].sort();
350
+ const hash = createHash('sha1');
351
+ hash.update(`code|defaultModelId=${defaultModelId}|minDate=${minDate ?? ''}|files=${sortedFiles.length}\n`);
352
+ const nonEmptyFiles = [];
353
+ for (const filePath of sortedFiles) {
354
+ try {
355
+ const st = await fs.stat(filePath);
356
+ hash.update(`${filePath}|${st.size}|${st.mtimeMs}\n`);
357
+ if (st.size > 0)
358
+ nonEmptyFiles.push(filePath);
359
+ }
360
+ catch {
361
+ // 忽略暂时不可访问的文件
362
+ }
363
+ }
364
+ const fingerprint = hash.digest('hex');
365
+ const cached = cacheGet('code', options.days);
366
+ if (cached?.fingerprint === fingerprint) {
367
+ if (profLog) {
368
+ const ms = performance.now() - t0;
369
+ console.error(`[cbs] loadCodeUsageData cacheHit ${ms.toFixed(1)}ms (files=${sortedFiles.length})`); // eslint-disable-line no-console
370
+ }
371
+ return cached.data;
372
+ }
373
+ // 并行解析(不落盘):多核并行 JSON.parse
374
+ if (useWorkers && nonEmptyFiles.length > 1) {
375
+ try {
376
+ const parsed = await parseCodeUsageWithWorkers(nonEmptyFiles, defaultModelId, minDate, workerCount);
377
+ const data = finalizeAnalysis(defaultModelId, parsed.dailyData, parsed.modelTotals, parsed.projectTotals, parsed.grandTotal);
378
+ cacheSet('code', options.days, { fingerprint, data });
379
+ if (profLog) {
380
+ const ms = performance.now() - t0;
381
+ console.error(`[cbs] loadCodeUsageData workers=${Math.min(workerCount, nonEmptyFiles.length)} ${ms.toFixed(1)}ms (files=${sortedFiles.length})`); // eslint-disable-line no-console
382
+ }
383
+ return data;
384
+ }
385
+ catch (err) {
386
+ if (profLog) {
387
+ console.error(`[cbs] loadCodeUsageData workersFailed; fallback to single-thread: ${err?.message ?? String(err)}`); // eslint-disable-line no-console
388
+ }
389
+ // fallback: 单线程解析
390
+ }
391
+ }
164
392
  const dailyData = {};
165
393
  const modelTotals = {};
166
394
  const projectTotals = {};
@@ -171,10 +399,7 @@ async function loadCodeUsageData(options = {}) {
171
399
  cacheHitTokens: 0,
172
400
  cacheMissTokens: 0,
173
401
  };
174
- for (const filePath of jsonlFiles) {
175
- const fileStat = await fs.stat(filePath);
176
- if (fileStat.size === 0)
177
- continue;
402
+ for (const filePath of nonEmptyFiles) {
178
403
  const fileStream = fsSync.createReadStream(filePath);
179
404
  const rl = createInterface({
180
405
  input: fileStream,
@@ -228,7 +453,13 @@ async function loadCodeUsageData(options = {}) {
228
453
  }
229
454
  }
230
455
  }
231
- return finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal);
456
+ const data = finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal);
457
+ cacheSet('code', options.days, { fingerprint, data });
458
+ if (profLog) {
459
+ const ms = performance.now() - t0;
460
+ console.error(`[cbs] loadCodeUsageData ${ms.toFixed(1)}ms (files=${sortedFiles.length})`); // eslint-disable-line no-console
461
+ }
462
+ return data;
232
463
  }
233
464
  async function findIdeHistoryDirs() {
234
465
  const root = getIdeDataDir();
@@ -329,6 +560,9 @@ async function inferIdeModelIdForRequest(conversationDir, request, messageModelC
329
560
  return null;
330
561
  }
331
562
  async function loadIdeUsageData(options = {}) {
563
+ const prof = process.env.CBS_PROFILE === '1';
564
+ const profLog = prof && (process.env.CBS_PROFILE_TUI === '1' || process.argv.includes('--no-tui'));
565
+ const t0 = prof ? performance.now() : 0;
332
566
  const defaultModelId = await loadModelFromSettings();
333
567
  const minDate = computeMinDate(options.days);
334
568
  // 加载工作区映射
@@ -344,6 +578,48 @@ async function loadIdeUsageData(options = {}) {
344
578
  cacheMissTokens: 0,
345
579
  };
346
580
  const historyDirs = await findIdeHistoryDirs();
581
+ // 同进程内存缓存(不落盘):refresh 时如果 IDE 索引未变,直接复用结果
582
+ const sortedHistoryDirs = [...historyDirs].sort();
583
+ const ideHash = createHash('sha1');
584
+ ideHash.update(`ide|defaultModelId=${defaultModelId}|minDate=${minDate ?? ''}|historyDirs=${sortedHistoryDirs.length}\n`);
585
+ const mappingKeys = [...workspaceMappings.keys()].sort();
586
+ ideHash.update(`mappings=${mappingKeys.length}\n`);
587
+ for (const k of mappingKeys) {
588
+ const v = workspaceMappings.get(k);
589
+ ideHash.update(`${k}|${v?.displayPath ?? ''}\n`);
590
+ }
591
+ for (const historyDir of sortedHistoryDirs) {
592
+ ideHash.update(`dir=${historyDir}\n`);
593
+ let workspaces = [];
594
+ try {
595
+ workspaces = await fs.readdir(historyDir, { withFileTypes: true });
596
+ }
597
+ catch {
598
+ continue;
599
+ }
600
+ for (const ws of workspaces) {
601
+ if (!ws.isDirectory())
602
+ continue;
603
+ const workspaceDir = path.join(historyDir, ws.name);
604
+ const workspaceIndexPath = path.join(workspaceDir, 'index.json');
605
+ try {
606
+ const st = await fs.stat(workspaceIndexPath);
607
+ ideHash.update(`${ws.name}|${st.size}|${st.mtimeMs}\n`);
608
+ }
609
+ catch {
610
+ // ignore
611
+ }
612
+ }
613
+ }
614
+ const fingerprint = ideHash.digest('hex');
615
+ const cached = cacheGet('ide', options.days);
616
+ if (cached?.fingerprint === fingerprint) {
617
+ if (profLog) {
618
+ const ms = performance.now() - t0;
619
+ console.error(`[cbs] loadIdeUsageData cacheHit ${ms.toFixed(1)}ms (historyDirs=${sortedHistoryDirs.length})`); // eslint-disable-line no-console
620
+ }
621
+ return cached.data;
622
+ }
347
623
  const messageModelCache = new Map();
348
624
  for (const historyDir of historyDirs) {
349
625
  let workspaces = [];
@@ -434,5 +710,11 @@ async function loadIdeUsageData(options = {}) {
434
710
  }
435
711
  }
436
712
  }
437
- return finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal, workspaceMappings);
713
+ const data = finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal, workspaceMappings);
714
+ cacheSet('ide', options.days, { fingerprint, data });
715
+ if (profLog) {
716
+ const ms = performance.now() - t0;
717
+ console.error(`[cbs] loadIdeUsageData ${ms.toFixed(1)}ms (historyDirs=${historyDirs.length})`); // eslint-disable-line no-console
718
+ }
719
+ return data;
438
720
  }
@@ -109,6 +109,24 @@ export const MODEL_PRICING = {
109
109
  },
110
110
  // GLM 系列 (价格从人民币转换: 1 USD = 7 CNY)
111
111
  // 按上下文长度分段定价:[0,32K), [32K,200K)
112
+ "glm-5.0": {
113
+ prompt: [
114
+ { limit: 32_000, pricePerMTok: 0.58 }, // 4元/M tokens
115
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.87 }, // 6元/M tokens
116
+ ],
117
+ completion: [
118
+ { limit: 32_000, pricePerMTok: 2.6 }, // 18元/M tokens
119
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 3.18 }, // 22元/M tokens
120
+ ],
121
+ cacheRead: [
122
+ { limit: 32_000, pricePerMTok: 0.14 }, // 1元/M tokens
123
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.22 }, // 1.5元/M tokens
124
+ ],
125
+ cacheWrite: [
126
+ { limit: 32_000, pricePerMTok: 0 },
127
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0 },
128
+ ],
129
+ },
112
130
  "glm-4.7": {
113
131
  prompt: [
114
132
  { limit: 32_000, pricePerMTok: 0.286 }, // 2元/M tokens
@@ -123,8 +141,8 @@ export const MODEL_PRICING = {
123
141
  { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.114 }, // 0.8元/M tokens
124
142
  ],
125
143
  cacheWrite: [
126
- { limit: 32_000, pricePerMTok: 0.286 }, // 2元/M tokens
127
- { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.571 }, // 4元/M tokens
144
+ { limit: 32_000, pricePerMTok: 0 },
145
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0 },
128
146
  ],
129
147
  },
130
148
  "glm-4.6": {
@@ -141,8 +159,8 @@ export const MODEL_PRICING = {
141
159
  { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.114 }, // 0.8元/M tokens
142
160
  ],
143
161
  cacheWrite: [
144
- { limit: 32_000, pricePerMTok: 0.286 }, // 2元/M tokens
145
- { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.571 }, // 4元/M tokens
162
+ { limit: 32_000, pricePerMTok: 0 },
163
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0 },
146
164
  ],
147
165
  },
148
166
  "glm-4.6v": {
@@ -158,9 +176,7 @@ export const MODEL_PRICING = {
158
176
  { limit: 32_000, pricePerMTok: 0.03 }, // 0.2元/M tokens
159
177
  { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.06 }, // 0.4元/M tokens
160
178
  ],
161
- cacheWrite: [
162
- { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0 },
163
- ]
179
+ cacheWrite: [{ limit: Number.POSITIVE_INFINITY, pricePerMTok: 0 }],
164
180
  },
165
181
  // DeepSeek 系列
166
182
  "deepseek-v3.2-volc": createPricing(0.29, 0.029, 0.43),
@@ -171,6 +187,7 @@ export const MODEL_PRICING = {
171
187
  "kimi-k2-thinking": createPricing(0.6, 0.15, 2.5),
172
188
  // iOA 免费模型
173
189
  "kimi-k2.5-ioa": createPricing(0, 0, 0),
190
+ "glm-5.0-ioa": createPricing(0, 0, 0),
174
191
  "glm-4.7-ioa": createPricing(0, 0, 0),
175
192
  "glm-4.6-ioa": createPricing(0, 0, 0),
176
193
  "glm-4.6v-ioa": createPricing(0, 0, 0),
@@ -20,7 +20,7 @@ export function renderByProject(box, data, scrollOffset = 0, width, note, pageSi
20
20
  const visibleProjects = sorted.slice(scrollOffset, scrollOffset + safePageSize);
21
21
  for (const [projectName, stats] of visibleProjects) {
22
22
  // 简化项目名
23
- const shortName = resolveProjectName(projectName, data.workspaceMappings);
23
+ const shortName = data.projectDisplayNames?.[projectName] ?? resolveProjectName(projectName, data.workspaceMappings);
24
24
  content +=
25
25
  truncate(shortName, projectCol - 1).padEnd(projectCol) +
26
26
  formatCost(stats.cost).padStart(12) +
@@ -10,7 +10,7 @@ export function renderDailyDetail(box, data, date, scrollOffset = 0, width, page
10
10
  }
11
11
  const projectDetails = [];
12
12
  for (const [projectName, models] of Object.entries(dayData)) {
13
- const shortName = resolveProjectName(projectName, data.workspaceMappings);
13
+ const shortName = data.projectDisplayNames?.[projectName] ?? resolveProjectName(projectName, data.workspaceMappings);
14
14
  const modelList = [];
15
15
  let totalCost = 0;
16
16
  let totalTokens = 0;
@@ -31,23 +31,31 @@ export function renderDaily(box, data, scrollOffset = 0, selectedIndex = 0, widt
31
31
  const dayData = dailyData[date];
32
32
  if (!daySummary || !dayData)
33
33
  continue;
34
- // 找出当天 top model 和 project
34
+ // 找出当天 top model 和 project(优先使用预计算缓存)
35
35
  let topModel = { id: '-', cost: 0 };
36
36
  let topProject = { name: '-', cost: 0 };
37
- for (const [project, models] of Object.entries(dayData)) {
38
- let projectCost = 0;
39
- for (const [model, stats] of Object.entries(models)) {
40
- const modelStats = stats;
41
- projectCost += Number(modelStats.cost ?? 0);
42
- if (Number(modelStats.cost ?? 0) > topModel.cost) {
43
- topModel = { id: model, cost: Number(modelStats.cost ?? 0) };
37
+ const cachedTop = data.dailyTops?.[date];
38
+ if (cachedTop) {
39
+ topModel = { id: cachedTop.topModelId || '-', cost: cachedTop.topModelCost || 0 };
40
+ topProject = { name: cachedTop.topProjectName || '-', cost: cachedTop.topProjectCost || 0 };
41
+ }
42
+ else {
43
+ for (const [project, models] of Object.entries(dayData)) {
44
+ let projectCost = 0;
45
+ for (const [model, stats] of Object.entries(models)) {
46
+ const modelStats = stats;
47
+ const c = Number(modelStats.cost ?? 0);
48
+ projectCost += c;
49
+ if (c > topModel.cost) {
50
+ topModel = { id: model, cost: c };
51
+ }
52
+ }
53
+ if (projectCost > topProject.cost) {
54
+ topProject = { name: project, cost: projectCost };
44
55
  }
45
- }
46
- if (projectCost > topProject.cost) {
47
- topProject = { name: project, cost: projectCost };
48
56
  }
49
57
  }
50
- const shortProject = resolveProjectName(topProject.name, data.workspaceMappings);
58
+ const shortProject = data.projectDisplayNames?.[topProject.name] ?? resolveProjectName(topProject.name, data.workspaceMappings);
51
59
  const isSelected = scrollOffset + i === selectedIndex;
52
60
  const rowContent = date.padEnd(dateCol) +
53
61
  formatCost(daySummary.cost).padStart(costCol) +
@@ -1,62 +1,99 @@
1
1
  import { resolveProjectName } from '../lib/workspace-resolver.js';
2
2
  import { formatCost, formatNumber, formatPercent, formatTokens, truncate } from '../lib/utils.js';
3
3
  export function renderMonthlyDetail(box, data, month, scrollOffset = 0, width, pageSize) {
4
- const aggByProject = {};
4
+ let projects = [];
5
5
  let totalCost = 0;
6
6
  let totalTokens = 0;
7
7
  let totalRequests = 0;
8
- let cacheHitTokens = 0;
9
- let cacheMissTokens = 0;
10
- for (const [date, dayData] of Object.entries(data.dailyData)) {
11
- if (!date.startsWith(month))
12
- continue;
13
- for (const [projectName, models] of Object.entries(dayData ?? {})) {
14
- aggByProject[projectName] ??= {
8
+ let cacheHitRate = 0;
9
+ const cachedMonthAgg = data.monthlyAgg?.[month];
10
+ const cachedByProject = data.monthlyByProject?.[month];
11
+ if (cachedMonthAgg && cachedByProject) {
12
+ totalCost = cachedMonthAgg.cost;
13
+ totalTokens = cachedMonthAgg.tokens;
14
+ totalRequests = cachedMonthAgg.requests;
15
+ cacheHitRate =
16
+ cachedMonthAgg.cacheHitTokens + cachedMonthAgg.cacheMissTokens > 0
17
+ ? cachedMonthAgg.cacheHitTokens / (cachedMonthAgg.cacheHitTokens + cachedMonthAgg.cacheMissTokens)
18
+ : 0;
19
+ projects = Object.entries(cachedByProject)
20
+ .map(([projectName, agg]) => {
21
+ let topModelId = '-';
22
+ let topModelCost = 0;
23
+ for (const [modelId, c] of Object.entries(agg.modelCost)) {
24
+ if (c > topModelCost) {
25
+ topModelCost = c;
26
+ topModelId = modelId;
27
+ }
28
+ }
29
+ const shortName = data.projectDisplayNames?.[projectName] ?? resolveProjectName(projectName, data.workspaceMappings);
30
+ return {
15
31
  name: projectName,
16
- shortName: resolveProjectName(projectName, data.workspaceMappings),
17
- cost: 0,
18
- tokens: 0,
19
- requests: 0,
20
- modelCost: {},
32
+ shortName,
33
+ cost: agg.cost,
34
+ tokens: agg.tokens,
35
+ requests: agg.requests,
36
+ modelCost: agg.modelCost,
37
+ topModelId,
21
38
  };
22
- const p = aggByProject[projectName];
23
- for (const [modelId, stats] of Object.entries(models ?? {})) {
24
- const s = stats;
25
- const cost = Number(s.cost ?? 0);
26
- const tokens = Number(s.totalTokens ?? 0);
27
- const requests = Number(s.requests ?? 0);
28
- const cacheHit = Number(s.cacheHitTokens ?? 0);
29
- const cacheMiss = Number(s.cacheMissTokens ?? 0);
30
- p.cost += cost;
31
- p.tokens += tokens;
32
- p.requests += requests;
33
- p.modelCost[modelId] = (p.modelCost[modelId] ?? 0) + cost;
34
- totalCost += cost;
35
- totalTokens += tokens;
36
- totalRequests += requests;
37
- cacheHitTokens += cacheHit;
38
- cacheMissTokens += cacheMiss;
39
- }
40
- }
39
+ })
40
+ .sort((a, b) => b.cost - a.cost);
41
41
  }
42
- const projects = Object.values(aggByProject)
43
- .map(p => {
44
- let topModelId = '-';
45
- let topModelCost = 0;
46
- for (const [modelId, c] of Object.entries(p.modelCost)) {
47
- if (c > topModelCost) {
48
- topModelCost = c;
49
- topModelId = modelId;
42
+ else {
43
+ const aggByProject = {};
44
+ let cacheHitTokens = 0;
45
+ let cacheMissTokens = 0;
46
+ for (const [date, dayData] of Object.entries(data.dailyData)) {
47
+ if (!date.startsWith(month))
48
+ continue;
49
+ for (const [projectName, models] of Object.entries(dayData ?? {})) {
50
+ aggByProject[projectName] ??= {
51
+ name: projectName,
52
+ shortName: data.projectDisplayNames?.[projectName] ?? resolveProjectName(projectName, data.workspaceMappings),
53
+ cost: 0,
54
+ tokens: 0,
55
+ requests: 0,
56
+ modelCost: {},
57
+ };
58
+ const p = aggByProject[projectName];
59
+ for (const [modelId, stats] of Object.entries(models ?? {})) {
60
+ const s = stats;
61
+ const cost = Number(s.cost ?? 0);
62
+ const tokens = Number(s.totalTokens ?? 0);
63
+ const requests = Number(s.requests ?? 0);
64
+ const cacheHit = Number(s.cacheHitTokens ?? 0);
65
+ const cacheMiss = Number(s.cacheMissTokens ?? 0);
66
+ p.cost += cost;
67
+ p.tokens += tokens;
68
+ p.requests += requests;
69
+ p.modelCost[modelId] = (p.modelCost[modelId] ?? 0) + cost;
70
+ totalCost += cost;
71
+ totalTokens += tokens;
72
+ totalRequests += requests;
73
+ cacheHitTokens += cacheHit;
74
+ cacheMissTokens += cacheMiss;
75
+ }
50
76
  }
51
77
  }
52
- return { ...p, topModelId };
53
- })
54
- .sort((a, b) => b.cost - a.cost);
78
+ projects = Object.values(aggByProject)
79
+ .map(p => {
80
+ let topModelId = '-';
81
+ let topModelCost = 0;
82
+ for (const [modelId, c] of Object.entries(p.modelCost)) {
83
+ if (c > topModelCost) {
84
+ topModelCost = c;
85
+ topModelId = modelId;
86
+ }
87
+ }
88
+ return { ...p, topModelId };
89
+ })
90
+ .sort((a, b) => b.cost - a.cost);
91
+ cacheHitRate = cacheHitTokens + cacheMissTokens > 0 ? cacheHitTokens / (cacheHitTokens + cacheMissTokens) : 0;
92
+ }
55
93
  if (!projects.length) {
56
94
  box.setContent(`{bold}${month}{/bold}\n\nNo data available for this month.`);
57
95
  return;
58
96
  }
59
- const cacheHitRate = cacheHitTokens + cacheMissTokens > 0 ? cacheHitTokens / (cacheHitTokens + cacheMissTokens) : 0;
60
97
  // 根据宽度计算列宽
61
98
  const availableWidth = width - 6; // padding
62
99
  const costCol = 12;
@@ -1,40 +1,42 @@
1
1
  import { resolveProjectName } from '../lib/workspace-resolver.js';
2
2
  import { formatCost, formatNumber, formatTokens, truncate } from '../lib/utils.js';
3
3
  export function renderMonthly(box, data, scrollOffset = 0, selectedIndex = 0, width, note, pageSize) {
4
- const aggByMonth = {};
5
- for (const [date, dayData] of Object.entries(data.dailyData)) {
6
- const month = date.slice(0, 7);
7
- if (!month)
8
- continue;
9
- aggByMonth[month] ??= {
10
- cost: 0,
11
- tokens: 0,
12
- requests: 0,
13
- cacheHitTokens: 0,
14
- cacheMissTokens: 0,
15
- modelCost: {},
16
- projectCost: {},
17
- };
18
- const monthAgg = aggByMonth[month];
19
- for (const [projectName, models] of Object.entries(dayData ?? {})) {
20
- for (const [modelId, stats] of Object.entries(models ?? {})) {
21
- const s = stats;
22
- const cost = Number(s.cost ?? 0);
23
- const tokens = Number(s.totalTokens ?? 0);
24
- const requests = Number(s.requests ?? 0);
25
- const cacheHit = Number(s.cacheHitTokens ?? 0);
26
- const cacheMiss = Number(s.cacheMissTokens ?? 0);
27
- monthAgg.cost += cost;
28
- monthAgg.tokens += tokens;
29
- monthAgg.requests += requests;
30
- monthAgg.cacheHitTokens += cacheHit;
31
- monthAgg.cacheMissTokens += cacheMiss;
32
- monthAgg.modelCost[modelId] = (monthAgg.modelCost[modelId] ?? 0) + cost;
33
- monthAgg.projectCost[projectName] = (monthAgg.projectCost[projectName] ?? 0) + cost;
4
+ const aggByMonth = data.monthlyAgg ?? {};
5
+ if (!data.monthlyAgg) {
6
+ for (const [date, dayData] of Object.entries(data.dailyData)) {
7
+ const month = date.slice(0, 7);
8
+ if (!month)
9
+ continue;
10
+ aggByMonth[month] ??= {
11
+ cost: 0,
12
+ tokens: 0,
13
+ requests: 0,
14
+ cacheHitTokens: 0,
15
+ cacheMissTokens: 0,
16
+ modelCost: {},
17
+ projectCost: {},
18
+ };
19
+ const monthAgg = aggByMonth[month];
20
+ for (const [projectName, models] of Object.entries(dayData ?? {})) {
21
+ for (const [modelId, stats] of Object.entries(models ?? {})) {
22
+ const s = stats;
23
+ const cost = Number(s.cost ?? 0);
24
+ const tokens = Number(s.totalTokens ?? 0);
25
+ const requests = Number(s.requests ?? 0);
26
+ const cacheHit = Number(s.cacheHitTokens ?? 0);
27
+ const cacheMiss = Number(s.cacheMissTokens ?? 0);
28
+ monthAgg.cost += cost;
29
+ monthAgg.tokens += tokens;
30
+ monthAgg.requests += requests;
31
+ monthAgg.cacheHitTokens += cacheHit;
32
+ monthAgg.cacheMissTokens += cacheMiss;
33
+ monthAgg.modelCost[modelId] = (monthAgg.modelCost[modelId] ?? 0) + cost;
34
+ monthAgg.projectCost[projectName] = (monthAgg.projectCost[projectName] ?? 0) + cost;
35
+ }
34
36
  }
35
37
  }
36
38
  }
37
- const sortedMonths = Object.keys(aggByMonth).sort().reverse();
39
+ const sortedMonths = data.sortedMonths ?? Object.keys(aggByMonth).sort().reverse();
38
40
  // 根据宽度计算列宽
39
41
  const availableWidth = width - 6; // padding
40
42
  const monthCol = 9;
@@ -73,7 +75,7 @@ export function renderMonthly(box, data, scrollOffset = 0, selectedIndex = 0, wi
73
75
  if (c > topProject.cost)
74
76
  topProject = { name: projectName, cost: c };
75
77
  }
76
- const shortProject = resolveProjectName(topProject.name, data.workspaceMappings);
78
+ const shortProject = data.projectDisplayNames?.[topProject.name] ?? resolveProjectName(topProject.name, data.workspaceMappings);
77
79
  const isSelected = scrollOffset + i === selectedIndex;
78
80
  const rowContent = month.padEnd(monthCol) +
79
81
  formatCost(m.cost).padStart(costCol) +
@@ -170,7 +170,7 @@ export function renderOverview(box, data, width, height, note) {
170
170
  }
171
171
  if (topProject) {
172
172
  const label = '{cyan-fg}Top project:{/cyan-fg} ';
173
- const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
173
+ const shortName = data.projectDisplayNames?.[topProject.name] ?? resolveProjectName(topProject.name, data.workspaceMappings);
174
174
  const tail = `(${formatCost(topProject.cost)})`;
175
175
  const maxNameLen = Math.max(4, maxW - visibleLen(label) - visibleLen(tail) - 2);
176
176
  lines.push(label + truncate(shortName, maxNameLen) + ' ' + tail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebuddy-stats",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [