codebuddy-stats 1.0.0 → 1.1.0

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.
@@ -3,8 +3,9 @@ import fsSync from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { createInterface } from 'node:readline'
5
5
 
6
- import { getProjectsDir, getSettingsPath } from './paths.js'
6
+ import { getIdeDataDir, getProjectsDir, getSettingsPath } from './paths.js'
7
7
  import { DEFAULT_MODEL_ID, getPricingForModel, tokensToCost } from './pricing.js'
8
+ import { loadWorkspaceMappings, type WorkspaceMapping } from './workspace-resolver.js'
8
9
 
9
10
  export const BASE_DIR = getProjectsDir()
10
11
 
@@ -55,10 +56,15 @@ export interface AnalysisData {
55
56
  topProject: (SummaryStats & { name: string }) | null
56
57
  cacheHitRate: number
57
58
  activeDays: number
59
+ /** 工作区 hash -> 路径映射(仅 IDE source 有效) */
60
+ workspaceMappings?: Map<string, import('./workspace-resolver.js').WorkspaceMapping>
58
61
  }
59
62
 
63
+ export type UsageSource = 'code' | 'ide'
64
+
60
65
  export interface LoadUsageOptions {
61
66
  days?: number | null
67
+ source?: UsageSource
62
68
  }
63
69
 
64
70
  interface SettingsFile {
@@ -133,10 +139,7 @@ function extractUsageStats(usage: RawUsage): UsageStats {
133
139
  }
134
140
  }
135
141
 
136
- function computeUsageCost(
137
- usage: RawUsage,
138
- modelId: string | null | undefined
139
- ): { cost: number; stats: UsageStats; modelId: string } {
142
+ function computeUsageCost(usage: RawUsage, modelId: string): { cost: number; stats: UsageStats } {
140
143
  const stats = extractUsageStats(usage)
141
144
  const pricing = getPricingForModel(modelId)
142
145
 
@@ -151,35 +154,111 @@ function computeUsageCost(
151
154
 
152
155
  const outputCost = tokensToCost(stats.completionTokens, pricing.completion)
153
156
 
154
- return { cost: inputCost + outputCost, stats, modelId: modelId || DEFAULT_MODEL_ID }
157
+ return { cost: inputCost + outputCost, stats }
158
+ }
159
+
160
+ function computeMinDate(days?: number | null): string | null {
161
+ if (!days) return null
162
+ const d = new Date()
163
+ d.setDate(d.getDate() - days + 1)
164
+ d.setHours(0, 0, 0, 0)
165
+ return d.toISOString().split('T')[0] ?? null
166
+ }
167
+
168
+ function toISODateString(value: unknown): string | null {
169
+ if (typeof value !== 'string') return null
170
+ const d = new Date(value)
171
+ if (Number.isNaN(d.getTime())) return null
172
+ return d.toISOString().split('T')[0] ?? null
173
+ }
174
+
175
+ async function pathExists(p: string): Promise<boolean> {
176
+ try {
177
+ await fs.access(p)
178
+ return true
179
+ } catch {
180
+ return false
181
+ }
182
+ }
183
+
184
+ function ensureDailyModelStats(dailyData: DailyData, date: string, project: string, modelId: string): DailyModelStats {
185
+ dailyData[date] ??= {}
186
+ dailyData[date]![project] ??= {}
187
+ dailyData[date]![project]![modelId] ??= {
188
+ cost: 0,
189
+ promptTokens: 0,
190
+ completionTokens: 0,
191
+ totalTokens: 0,
192
+ cacheHitTokens: 0,
193
+ cacheMissTokens: 0,
194
+ cacheWriteTokens: 0,
195
+ requests: 0,
196
+ }
197
+ return dailyData[date]![project]![modelId]!
198
+ }
199
+
200
+ function finalizeAnalysis(
201
+ defaultModelId: string,
202
+ dailyData: DailyData,
203
+ modelTotals: Record<string, SummaryStats>,
204
+ projectTotals: Record<string, SummaryStats>,
205
+ grandTotal: GrandTotal,
206
+ workspaceMappings?: Map<string, WorkspaceMapping>
207
+ ): AnalysisData {
208
+ const dailySummary: Record<string, SummaryStats> = {}
209
+ for (const date of Object.keys(dailyData)) {
210
+ dailySummary[date] = { cost: 0, tokens: 0, requests: 0 }
211
+ for (const project of Object.values(dailyData[date] ?? {})) {
212
+ for (const model of Object.values(project ?? {})) {
213
+ dailySummary[date]!.cost += model.cost
214
+ dailySummary[date]!.tokens += model.totalTokens
215
+ dailySummary[date]!.requests += model.requests
216
+ }
217
+ }
218
+ }
219
+
220
+ const topModelEntry = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)[0]
221
+ const topProjectEntry = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost)[0]
222
+
223
+ const cacheHitRate =
224
+ grandTotal.cacheHitTokens + grandTotal.cacheMissTokens > 0
225
+ ? grandTotal.cacheHitTokens / (grandTotal.cacheHitTokens + grandTotal.cacheMissTokens)
226
+ : 0
227
+
228
+ return {
229
+ defaultModelId,
230
+ dailyData,
231
+ dailySummary,
232
+ modelTotals,
233
+ projectTotals,
234
+ grandTotal,
235
+ topModel: topModelEntry ? { id: topModelEntry[0], ...topModelEntry[1] } : null,
236
+ topProject: topProjectEntry ? { name: topProjectEntry[0], ...topProjectEntry[1] } : null,
237
+ cacheHitRate,
238
+ activeDays: Object.keys(dailyData).length,
239
+ workspaceMappings,
240
+ }
155
241
  }
156
242
 
157
243
  /**
158
244
  * 加载所有用量数据
159
245
  */
160
246
  export async function loadUsageData(options: LoadUsageOptions = {}): Promise<AnalysisData> {
247
+ const source: UsageSource = options.source ?? 'code'
248
+ if (source === 'ide') {
249
+ return loadIdeUsageData(options)
250
+ }
251
+ return loadCodeUsageData(options)
252
+ }
253
+
254
+ async function loadCodeUsageData(options: LoadUsageOptions = {}): Promise<AnalysisData> {
161
255
  const defaultModelId = await loadModelFromSettings()
162
256
  const jsonlFiles = await findJsonlFiles(BASE_DIR)
257
+ const minDate = computeMinDate(options.days)
163
258
 
164
- // 计算日期过滤范围
165
- let minDate: string | null = null
166
- if (options.days) {
167
- const d = new Date()
168
- d.setDate(d.getDate() - options.days + 1)
169
- d.setHours(0, 0, 0, 0)
170
- minDate = d.toISOString().split('T')[0] ?? null
171
- }
172
-
173
- // 按日期 -> 项目 -> 模型 组织的数据
174
259
  const dailyData: DailyData = {}
175
-
176
- // 按模型汇总
177
260
  const modelTotals: Record<string, SummaryStats> = {}
178
-
179
- // 按项目汇总
180
261
  const projectTotals: Record<string, SummaryStats> = {}
181
-
182
- // 总计
183
262
  const grandTotal: GrandTotal = {
184
263
  cost: 0,
185
264
  tokens: 0,
@@ -213,27 +292,15 @@ export async function loadUsageData(options: LoadUsageOptions = {}): Promise<Ana
213
292
  const date = dateObj.toISOString().split('T')[0]
214
293
  if (!date) continue
215
294
 
216
- // 日期过滤
217
295
  if (minDate && date < minDate) continue
218
296
 
219
297
  const recordModelId = record?.providerData?.model
220
- const modelId = typeof recordModelId === 'string' ? recordModelId : null
221
- const { cost, stats: usageStats, modelId: usedModelId } = computeUsageCost(usage, modelId)
222
-
223
- dailyData[date] ??= {}
224
- dailyData[date]![projectName] ??= {}
225
- dailyData[date]![projectName]![usedModelId] ??= {
226
- cost: 0,
227
- promptTokens: 0,
228
- completionTokens: 0,
229
- totalTokens: 0,
230
- cacheHitTokens: 0,
231
- cacheMissTokens: 0,
232
- cacheWriteTokens: 0,
233
- requests: 0,
234
- }
298
+ const modelFromRecord = typeof recordModelId === 'string' ? recordModelId : null
299
+ const usedModelId = modelFromRecord || defaultModelId
235
300
 
236
- const dayStats = dailyData[date]![projectName]![usedModelId]!
301
+ const { cost, stats: usageStats } = computeUsageCost(usage, usedModelId)
302
+
303
+ const dayStats = ensureDailyModelStats(dailyData, date, projectName, usedModelId)
237
304
  dayStats.cost += cost
238
305
  dayStats.promptTokens += usageStats.promptTokens
239
306
  dayStats.completionTokens += usageStats.completionTokens
@@ -264,39 +331,251 @@ export async function loadUsageData(options: LoadUsageOptions = {}): Promise<Ana
264
331
  }
265
332
  }
266
333
 
267
- // 计算每日汇总
268
- const dailySummary: Record<string, SummaryStats> = {}
269
- for (const date of Object.keys(dailyData)) {
270
- dailySummary[date] = { cost: 0, tokens: 0, requests: 0 }
271
- for (const project of Object.values(dailyData[date] ?? {})) {
272
- for (const model of Object.values(project ?? {})) {
273
- dailySummary[date]!.cost += model.cost
274
- dailySummary[date]!.tokens += model.totalTokens
275
- dailySummary[date]!.requests += model.requests
334
+ return finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal)
335
+ }
336
+
337
+ interface IdeConversationMeta {
338
+ id?: unknown
339
+ createdAt?: unknown
340
+ lastMessageAt?: unknown
341
+ }
342
+
343
+ interface IdeRequestUsage {
344
+ inputTokens?: unknown
345
+ outputTokens?: unknown
346
+ totalTokens?: unknown
347
+ }
348
+
349
+ interface IdeRequest {
350
+ messages?: unknown
351
+ usage?: IdeRequestUsage
352
+ }
353
+
354
+ interface IdeConversationIndex {
355
+ requests?: unknown
356
+ }
357
+
358
+ async function findIdeHistoryDirs(): Promise<string[]> {
359
+ const root = getIdeDataDir()
360
+ const out = new Set<string>()
361
+
362
+ let level1: fsSync.Dirent[] = []
363
+ try {
364
+ level1 = await fs.readdir(root, { withFileTypes: true })
365
+ } catch {
366
+ return []
367
+ }
368
+
369
+ for (const dirent of level1) {
370
+ if (!dirent.isDirectory()) continue
371
+ const codeBuddyIdeDir = path.join(root, dirent.name, 'CodeBuddyIDE')
372
+ if (!(await pathExists(codeBuddyIdeDir))) continue
373
+
374
+ const directHistory = path.join(codeBuddyIdeDir, 'history')
375
+ if (await pathExists(directHistory)) {
376
+ out.add(directHistory)
377
+ continue
378
+ }
379
+
380
+ let nested: fsSync.Dirent[] = []
381
+ try {
382
+ nested = await fs.readdir(codeBuddyIdeDir, { withFileTypes: true })
383
+ } catch {
384
+ continue
385
+ }
386
+
387
+ for (const child of nested) {
388
+ if (!child.isDirectory()) continue
389
+ const nestedHistory = path.join(codeBuddyIdeDir, child.name, 'history')
390
+ if (await pathExists(nestedHistory)) {
391
+ out.add(nestedHistory)
276
392
  }
277
393
  }
278
394
  }
279
395
 
280
- const topModelEntry = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)[0]
281
- const topProjectEntry =
282
- Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost)[0]
396
+ return [...out]
397
+ }
283
398
 
284
- // 计算缓存命中率
285
- const cacheHitRate =
286
- grandTotal.cacheHitTokens + grandTotal.cacheMissTokens > 0
287
- ? grandTotal.cacheHitTokens / (grandTotal.cacheHitTokens + grandTotal.cacheMissTokens)
288
- : 0
399
+ async function readJsonFile(filePath: string): Promise<unknown> {
400
+ const raw = await fs.readFile(filePath, 'utf8')
401
+ return JSON.parse(raw) as unknown
402
+ }
289
403
 
290
- return {
291
- defaultModelId,
292
- dailyData,
293
- dailySummary,
294
- modelTotals,
295
- projectTotals,
296
- grandTotal,
297
- topModel: topModelEntry ? { id: topModelEntry[0], ...topModelEntry[1] } : null,
298
- topProject: topProjectEntry ? { name: topProjectEntry[0], ...topProjectEntry[1] } : null,
299
- cacheHitRate,
300
- activeDays: Object.keys(dailyData).length,
404
+ async function readFileHeadUtf8(filePath: string, bytes = 64 * 1024): Promise<string> {
405
+ const fh = await fs.open(filePath, 'r')
406
+ try {
407
+ const buf = Buffer.alloc(bytes)
408
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0)
409
+ return buf.toString('utf8', 0, bytesRead)
410
+ } finally {
411
+ await fh.close()
412
+ }
413
+ }
414
+
415
+ function extractModelIdFromMessageHead(head: string): string | null {
416
+ // extra 是一个转义的 JSON 字符串,形如: "extra": "{\"modelId\":\"gpt-5.1\",...}"
417
+ // 正则需要匹配包含转义字符的完整字符串
418
+ const extraMatch = head.match(/"extra"\s*:\s*"((?:[^"\\]|\\.)*)"/s)
419
+ if (!extraMatch?.[1]) return null
420
+
421
+ try {
422
+ // extraMatch[1] 是转义后的内容,需要先解析为字符串
423
+ const extraStr = JSON.parse(`"${extraMatch[1]}"`) as string
424
+ const extra = JSON.parse(extraStr) as { modelId?: unknown; modelName?: unknown }
425
+ if (typeof extra.modelId === 'string' && extra.modelId) return extra.modelId
426
+ if (typeof extra.modelName === 'string' && extra.modelName) return extra.modelName
427
+ return null
428
+ } catch {
429
+ return null
430
+ }
431
+ }
432
+
433
+ async function inferIdeModelIdForRequest(
434
+ conversationDir: string,
435
+ request: IdeRequest,
436
+ messageModelCache: Map<string, string>
437
+ ): Promise<string | null> {
438
+ const messages = Array.isArray(request.messages) ? (request.messages as unknown[]) : []
439
+
440
+ for (let i = 0; i < Math.min(messages.length, 3); i++) {
441
+ const messageId = messages[i]
442
+ if (typeof messageId !== 'string' || !messageId) continue
443
+
444
+ const cached = messageModelCache.get(messageId)
445
+ if (cached) return cached
446
+
447
+ const msgPath = path.join(conversationDir, 'messages', `${messageId}.json`)
448
+ try {
449
+ const head = await readFileHeadUtf8(msgPath)
450
+ const modelId = extractModelIdFromMessageHead(head)
451
+ if (modelId) {
452
+ messageModelCache.set(messageId, modelId)
453
+ return modelId
454
+ }
455
+ } catch {
456
+ // ignore
457
+ }
458
+ }
459
+
460
+ return null
461
+ }
462
+
463
+ async function loadIdeUsageData(options: LoadUsageOptions = {}): Promise<AnalysisData> {
464
+ const defaultModelId = await loadModelFromSettings()
465
+ const minDate = computeMinDate(options.days)
466
+
467
+ // 加载工作区映射
468
+ const workspaceMappings = await loadWorkspaceMappings()
469
+
470
+ const dailyData: DailyData = {}
471
+ const modelTotals: Record<string, SummaryStats> = {}
472
+ const projectTotals: Record<string, SummaryStats> = {}
473
+ const grandTotal: GrandTotal = {
474
+ cost: 0,
475
+ tokens: 0,
476
+ requests: 0,
477
+ cacheHitTokens: 0,
478
+ cacheMissTokens: 0,
301
479
  }
480
+
481
+ const historyDirs = await findIdeHistoryDirs()
482
+ const messageModelCache = new Map<string, string>()
483
+
484
+ for (const historyDir of historyDirs) {
485
+ let workspaces: fsSync.Dirent[] = []
486
+ try {
487
+ workspaces = await fs.readdir(historyDir, { withFileTypes: true })
488
+ } catch {
489
+ continue
490
+ }
491
+
492
+ for (const ws of workspaces) {
493
+ if (!ws.isDirectory()) continue
494
+ const workspaceHash = ws.name
495
+ const workspaceDir = path.join(historyDir, workspaceHash)
496
+ const workspaceIndexPath = path.join(workspaceDir, 'index.json')
497
+
498
+ let convList: IdeConversationMeta[] = []
499
+ try {
500
+ const parsed = (await readJsonFile(workspaceIndexPath)) as unknown
501
+ if (Array.isArray(parsed)) {
502
+ convList = parsed as IdeConversationMeta[]
503
+ } else if (parsed && typeof parsed === 'object') {
504
+ const maybe = (parsed as any).conversations ?? (parsed as any).items ?? (parsed as any).list
505
+ if (Array.isArray(maybe)) convList = maybe as IdeConversationMeta[]
506
+ }
507
+ } catch {
508
+ continue
509
+ }
510
+
511
+ for (const conv of convList) {
512
+ const conversationId = typeof conv.id === 'string' ? conv.id : null
513
+ if (!conversationId) continue
514
+
515
+ const date = toISODateString(conv.lastMessageAt) ?? toISODateString(conv.createdAt)
516
+ if (!date) continue
517
+ if (minDate && date < minDate) continue
518
+
519
+ const conversationDir = path.join(workspaceDir, conversationId)
520
+ const convIndexPath = path.join(conversationDir, 'index.json')
521
+
522
+ let convIndex: IdeConversationIndex | null = null
523
+ try {
524
+ convIndex = (await readJsonFile(convIndexPath)) as IdeConversationIndex
525
+ } catch {
526
+ continue
527
+ }
528
+
529
+ const requests = Array.isArray(convIndex?.requests) ? (convIndex!.requests as IdeRequest[]) : []
530
+ for (const req of requests) {
531
+ const usage = req?.usage
532
+ const inputTokens = typeof usage?.inputTokens === 'number' ? usage.inputTokens : Number(usage?.inputTokens ?? 0)
533
+ const outputTokens = typeof usage?.outputTokens === 'number' ? usage.outputTokens : Number(usage?.outputTokens ?? 0)
534
+ const totalTokens =
535
+ typeof usage?.totalTokens === 'number'
536
+ ? usage.totalTokens
537
+ : Number.isFinite(Number(usage?.totalTokens))
538
+ ? Number(usage?.totalTokens)
539
+ : inputTokens + outputTokens
540
+
541
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens) || !Number.isFinite(totalTokens)) continue
542
+
543
+ const inferredModelId = await inferIdeModelIdForRequest(conversationDir, req, messageModelCache)
544
+ const usedModelId = inferredModelId || defaultModelId
545
+
546
+ const rawUsage: RawUsage = {
547
+ prompt_tokens: Math.max(0, inputTokens),
548
+ completion_tokens: Math.max(0, outputTokens),
549
+ total_tokens: Math.max(0, totalTokens),
550
+ }
551
+
552
+ const { cost, stats } = computeUsageCost(rawUsage, usedModelId)
553
+
554
+ const projectName = workspaceHash
555
+ const dayStats = ensureDailyModelStats(dailyData, date, projectName, usedModelId)
556
+ dayStats.cost += cost
557
+ dayStats.promptTokens += stats.promptTokens
558
+ dayStats.completionTokens += stats.completionTokens
559
+ dayStats.totalTokens += stats.totalTokens
560
+ dayStats.requests += 1
561
+
562
+ modelTotals[usedModelId] ??= { cost: 0, tokens: 0, requests: 0 }
563
+ modelTotals[usedModelId]!.cost += cost
564
+ modelTotals[usedModelId]!.tokens += stats.totalTokens
565
+ modelTotals[usedModelId]!.requests += 1
566
+
567
+ projectTotals[projectName] ??= { cost: 0, tokens: 0, requests: 0 }
568
+ projectTotals[projectName]!.cost += cost
569
+ projectTotals[projectName]!.tokens += stats.totalTokens
570
+ projectTotals[projectName]!.requests += 1
571
+
572
+ grandTotal.cost += cost
573
+ grandTotal.tokens += stats.totalTokens
574
+ grandTotal.requests += 1
575
+ }
576
+ }
577
+ }
578
+ }
579
+
580
+ return finalizeAnalysis(defaultModelId, dailyData, modelTotals, projectTotals, grandTotal, workspaceMappings)
302
581
  }
package/src/lib/paths.ts CHANGED
@@ -22,6 +22,39 @@ export function getConfigDir(): string {
22
22
  return path.join(os.homedir(), '.codebuddy')
23
23
  }
24
24
 
25
+ /**
26
+ * 获取 CodeBuddy IDE (CodeBuddyExtension) 数据目录
27
+ * - macOS: ~/Library/Application Support/CodeBuddyExtension/Data
28
+ * - Windows: %APPDATA%/CodeBuddyExtension/Data
29
+ * - Linux: $XDG_CONFIG_HOME/CodeBuddyExtension/Data 或 ~/.config/CodeBuddyExtension/Data
30
+ */
31
+ export function getIdeDataDir(): string {
32
+ if (process.platform === 'darwin') {
33
+ return path.join(os.homedir(), 'Library', 'Application Support', 'CodeBuddyExtension', 'Data')
34
+ }
35
+ if (process.platform === 'win32') {
36
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
37
+ return path.join(appData, 'CodeBuddyExtension', 'Data')
38
+ }
39
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
40
+ return path.join(xdgConfigHome, 'CodeBuddyExtension', 'Data')
41
+ }
42
+
43
+ /**
44
+ * 获取 CodeBuddy IDE 的 workspaceStorage 目录
45
+ */
46
+ export function getWorkspaceStorageDir(): string {
47
+ if (process.platform === 'darwin') {
48
+ return path.join(os.homedir(), 'Library', 'Application Support', 'CodeBuddy CN', 'User', 'workspaceStorage')
49
+ }
50
+ if (process.platform === 'win32') {
51
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
52
+ return path.join(appData, 'CodeBuddy CN', 'User', 'workspaceStorage')
53
+ }
54
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
55
+ return path.join(configHome, 'CodeBuddy CN', 'User', 'workspaceStorage')
56
+ }
57
+
25
58
  /**
26
59
  * 获取项目数据目录
27
60
  */
@@ -35,11 +68,3 @@ export function getProjectsDir(): string {
35
68
  export function getSettingsPath(): string {
36
69
  return path.join(getConfigDir(), 'settings.json')
37
70
  }
38
-
39
- /**
40
- * 简化项目路径显示
41
- * 保持原始名称不变
42
- */
43
- export function shortenProjectName(name: string): string {
44
- return name
45
- }