ccus-cli 0.1.19 → 0.1.21

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.
@@ -16,6 +16,10 @@ const node_zlib_1 = require("node:zlib");
16
16
  const payload_1 = require("./payload");
17
17
  const time_1 = require("./time");
18
18
  const gunzipAsync = (0, node_util_1.promisify)(node_zlib_1.gunzip);
19
+ function maxOrNull(values) {
20
+ const numbers = values.filter((v) => v !== null);
21
+ return numbers.length > 0 ? Math.max(...numbers) : null;
22
+ }
19
23
  function isRecord(value) {
20
24
  return typeof value === "object" && value !== null && !Array.isArray(value);
21
25
  }
@@ -91,20 +95,29 @@ async function readBundleFileContent(filePath) {
91
95
  /** 读取目录里的 export bundle json 文件。 */
92
96
  async function loadWeeklyExportBundles(inputDir) {
93
97
  const files = await collectBundleJsonFiles(inputDir);
94
- const bundles = [];
95
98
  const invalidFiles = [];
96
- for (const filePath of files) {
99
+ const results = await Promise.all(files.map(async (filePath) => {
97
100
  try {
98
101
  const content = await readBundleFileContent(filePath);
99
102
  const parsed = JSON.parse(content);
100
103
  if (isWeeklyExportBundle(parsed)) {
101
- bundles.push({ filePath, bundle: parsed });
102
- continue;
104
+ return { filePath, bundle: parsed };
103
105
  }
104
- invalidFiles.push(filePath);
106
+ return { filePath, bundle: null };
105
107
  }
106
108
  catch {
109
+ return null;
110
+ }
111
+ }));
112
+ const bundles = [];
113
+ for (const result of results) {
114
+ if (result === null)
107
115
  continue;
116
+ if (result.bundle === null) {
117
+ invalidFiles.push(result.filePath);
118
+ }
119
+ else {
120
+ bundles.push({ filePath: result.filePath, bundle: result.bundle });
108
121
  }
109
122
  }
110
123
  if (invalidFiles.length > 0) {
@@ -140,31 +153,87 @@ function dayDataTier(day) {
140
153
  return 1;
141
154
  return 0;
142
155
  }
143
- /** winner 比较:数据质量等级高优先,同级别内 generatedAt 较新优先,最后用 filePath 做稳定 tie-break。 */
144
- function isBetterCandidate(nextTier, nextGeneratedAt, nextFilePath, currentTier, currentGeneratedAt, currentFilePath) {
156
+ /**
157
+ * winner 比较:数据质量等级高优先;同 tier=2 時消息数多优先(避免"仅 generatedAt 更新"的低活跃机器
158
+ * 覆盖同一天高活跃机器的数据);同 count 内 generatedAt 较新优先;最后用 filePath 做稳定 tie-break。
159
+ */
160
+ function isBetterCandidate(nextTier, nextMsgCount, nextApiCount, nextGeneratedAt, nextFilePath, currentTier, currentMsgCount, currentApiCount, currentGeneratedAt, currentFilePath) {
145
161
  if (nextTier !== currentTier) {
146
162
  return nextTier > currentTier;
147
163
  }
164
+ if (nextTier === 2) {
165
+ if (nextMsgCount !== currentMsgCount) {
166
+ return nextMsgCount > currentMsgCount;
167
+ }
168
+ if (nextApiCount !== currentApiCount) {
169
+ return nextApiCount > currentApiCount;
170
+ }
171
+ }
148
172
  if (nextGeneratedAt !== currentGeneratedAt) {
149
173
  return nextGeneratedAt > currentGeneratedAt;
150
174
  }
151
175
  return nextFilePath > currentFilePath;
152
176
  }
153
- /** 对每个 (personKey, date) 选出 generatedAt 最新、且尽量有数据的那份 bundle 当天数据。 */
154
- function selectDailyWinners(bundles) {
155
- const winners = new Map();
177
+ /**
178
+ * 同一 (personKey, date) 的所有 bundle,按 rawEvents sessionId 集合的交集分组:
179
+ * - 有交集的视为同机器重复导出(同一账号同一天的会话在两份 bundle 里均存在),只取最优 winner
180
+ * - 无交集的视为不同机器的独立数据,分别保留,后续叠加
181
+ *
182
+ * sessionId 集合为空的候选(该天没有 statusline 事件)不参与交集判断,单独成组。
183
+ */
184
+ function selectDailyRepresentatives(bundles) {
185
+ // 收集每个 (personKey, date) 的所有候选
186
+ const candidatesByKey = new Map();
156
187
  for (const { filePath, bundle } of bundles) {
157
188
  const personKey = bundlePersonKey(bundle);
158
189
  const generatedAt = bundle.generatedAt ?? "";
190
+ const eventsByDate = bundleEventsByDate(bundle);
159
191
  for (const day of bundle.dailySummaries) {
160
192
  const key = `${personKey}|${day.date}`;
161
- const current = winners.get(key);
162
- if (!current || isBetterCandidate(dayDataTier(day), generatedAt, filePath, dayDataTier(current.day), current.generatedAt, current.filePath)) {
163
- winners.set(key, { personKey, date: day.date, day, bundle, generatedAt, filePath });
193
+ const events = eventsByDate.get(day.date) ?? [];
194
+ const sessionIds = new Set(events.map((e) => e.sessionId).filter((s) => s !== null));
195
+ const list = candidatesByKey.get(key) ?? [];
196
+ list.push({ day, bundle, generatedAt, filePath, sessionIds });
197
+ candidatesByKey.set(key, list);
198
+ }
199
+ }
200
+ const result = new Map();
201
+ for (const [key, candidates] of candidatesByKey.entries()) {
202
+ const barIdx = key.indexOf("|");
203
+ const personKey = key.slice(0, barIdx);
204
+ const date = key.slice(barIdx + 1);
205
+ // 贪心分组:候选有 sessionId 且与某组内任意候选的 sessionId 有交集,则并入该组;否则新建组
206
+ const groups = [];
207
+ for (const candidate of candidates) {
208
+ let added = false;
209
+ if (candidate.sessionIds.size > 0) {
210
+ for (const group of groups) {
211
+ const hasOverlap = group.some((c) => c.sessionIds.size > 0 && [...candidate.sessionIds].some((s) => c.sessionIds.has(s)));
212
+ if (hasOverlap) {
213
+ group.push(candidate);
214
+ added = true;
215
+ break;
216
+ }
217
+ }
218
+ }
219
+ if (!added) {
220
+ groups.push([candidate]);
164
221
  }
165
222
  }
223
+ // 每组取最优代表(同机器多次导出只保留一份)
224
+ const reps = groups.map((group) => {
225
+ let best = group[0];
226
+ for (let i = 1; i < group.length; i++) {
227
+ const c = group[i];
228
+ if (isBetterCandidate(dayDataTier(c.day), c.day.userMessageCount, c.day.apiRequestCount, c.generatedAt, c.filePath, dayDataTier(best.day), best.day.userMessageCount, best.day.apiRequestCount, best.generatedAt, best.filePath)) {
229
+ best = c;
230
+ }
231
+ }
232
+ return { personKey, date, day: best.day, bundle: best.bundle };
233
+ });
234
+ result.set(key, reps);
166
235
  }
167
- return winners;
236
+ return result;
168
237
  }
169
238
  /** 把 bundle 的 rawEvents 计算成 StatuslineEvent 并按本地自然日分组,结果做缓存复用。 */
170
239
  const bundleEventsCache = new WeakMap();
@@ -346,52 +415,72 @@ function recomputeUsage(events) {
346
415
  sevenDayLatestUsagePct: newestFirst.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null,
347
416
  };
348
417
  }
349
- /** winner bundle 的事件展开 detail.csv,同人同天只保留 winner,那份的当天 token 总量随行附带。 */
418
+ /** 展开 detail.csv:同人同天各机器的代表 bundle 事件都列出来,token 总量随本机器当天的 daySummary 附带。 */
350
419
  function buildAggregatedDetailRows(bundles) {
351
- const winners = selectDailyWinners(bundles);
420
+ const repsMap = selectDailyRepresentatives(bundles);
352
421
  const rows = [];
353
- for (const winner of winners.values()) {
354
- const events = bundleEventsByDate(winner.bundle).get(winner.date) ?? [];
355
- for (const event of events) {
356
- rows.push({
357
- ...event,
358
- personKey: winner.personKey,
359
- weekKey: weekKey(new Date(event.timestamp)),
360
- dateKey: winner.date,
361
- inputTokens: winner.day.inputTokens,
362
- outputTokens: winner.day.outputTokens,
363
- cacheReadInputTokens: winner.day.cacheReadInputTokens,
364
- });
422
+ for (const reps of repsMap.values()) {
423
+ for (const rep of reps) {
424
+ const events = bundleEventsByDate(rep.bundle).get(rep.date) ?? [];
425
+ for (const event of events) {
426
+ rows.push({
427
+ ...event,
428
+ personKey: rep.personKey,
429
+ weekKey: weekKey(new Date(event.timestamp)),
430
+ dateKey: rep.date,
431
+ inputTokens: rep.day.inputTokens,
432
+ outputTokens: rep.day.outputTokens,
433
+ cacheReadInputTokens: rep.day.cacheReadInputTokens,
434
+ });
435
+ }
365
436
  }
366
437
  }
367
438
  return rows.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
368
439
  }
369
- /** 展开 daily.csv:同人同天取 winner bundle 的累加值,usage 从该 bundle 当天事件重算。 */
440
+ /**
441
+ * 展开 daily.csv:同人同天的不同机器数据直接叠加(计数字段相加),usage 从所有机器该天事件合并后重算。
442
+ * 同机器重复导出由 selectDailyRepresentatives 在分组阶段去重,不会翻倍。
443
+ */
370
444
  function buildAggregatedDailyRows(bundles) {
371
- const winners = selectDailyWinners(bundles);
445
+ const repsMap = selectDailyRepresentatives(bundles);
372
446
  const curves = buildPersonSevenDayCurve(bundles);
373
447
  const rows = [];
374
- for (const winner of winners.values()) {
375
- const day = winner.day;
376
- const usage = recomputeUsage(bundleEventsByDate(winner.bundle).get(winner.date) ?? []);
377
- // 累计指标走全样本合并曲线,不走 winner recomputeUsage,避免漏掉非 winner 机器的样本。
378
- const sevenDayCumulativeUsagePct = computeCumulativeSevenDay(sliceCurveByDate(curves.get(winner.personKey) ?? [], winner.date));
448
+ for (const reps of repsMap.values()) {
449
+ const { personKey, date } = reps[0];
450
+ // 不同机器的独立数据直接叠加
451
+ const userMessageCount = reps.reduce((sum, r) => sum + r.day.userMessageCount, 0);
452
+ const apiRequestCount = reps.reduce((sum, r) => sum + r.day.apiRequestCount, 0);
453
+ const inputTokens = reps.reduce((sum, r) => sum + r.day.inputTokens, 0);
454
+ const outputTokens = reps.reduce((sum, r) => sum + r.day.outputTokens, 0);
455
+ const cacheReadInputTokens = reps.reduce((sum, r) => sum + r.day.cacheReadInputTokens, 0);
456
+ const sampleCount = reps.reduce((sum, r) => sum + r.day.sampleCount, 0);
457
+ const uniqueSessions = reps.reduce((sum, r) => sum + r.day.uniqueSessions, 0);
458
+ const uniqueWorkspaces = reps.reduce((sum, r) => sum + r.day.uniqueWorkspaces, 0);
459
+ // usage 从所有机器该天事件合并后重算;rawEvents 缺失时用各代表 daySummary 回退
460
+ const allEvents = reps.flatMap((r) => bundleEventsByDate(r.bundle).get(date) ?? []);
461
+ const usage = recomputeUsage(allEvents);
462
+ const fiveHourPeakFallback = maxOrNull(reps.map((r) => r.day.fiveHourPeakUsagePct));
463
+ const sevenDayPeakFallback = maxOrNull(reps.map((r) => r.day.sevenDayPeakUsagePct));
464
+ const fiveHourLatestFallback = reps.find((r) => r.day.fiveHourLatestUsagePct !== null)?.day.fiveHourLatestUsagePct ?? null;
465
+ const sevenDayLatestFallback = reps.find((r) => r.day.sevenDayLatestUsagePct !== null)?.day.sevenDayLatestUsagePct ?? null;
466
+ // 累计指标走全样本合并曲线,不走单机的 recomputeUsage,避免漏掉另一台机器的样本。
467
+ const sevenDayCumulativeUsagePct = computeCumulativeSevenDay(sliceCurveByDate(curves.get(personKey) ?? [], date));
379
468
  rows.push({
380
- personKey: winner.personKey,
381
- date: day.date,
382
- userMessageCount: day.userMessageCount,
383
- apiRequestCount: day.apiRequestCount,
384
- inputTokens: day.inputTokens,
385
- outputTokens: day.outputTokens,
386
- cacheReadInputTokens: day.cacheReadInputTokens,
387
- sampleCount: day.sampleCount,
388
- fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? day.fiveHourPeakUsagePct,
389
- fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? day.fiveHourLatestUsagePct,
390
- sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? day.sevenDayPeakUsagePct,
391
- sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? day.sevenDayLatestUsagePct,
469
+ personKey,
470
+ date,
471
+ userMessageCount,
472
+ apiRequestCount,
473
+ inputTokens,
474
+ outputTokens,
475
+ cacheReadInputTokens,
476
+ sampleCount,
477
+ fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? fiveHourPeakFallback,
478
+ fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? fiveHourLatestFallback,
479
+ sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? sevenDayPeakFallback,
480
+ sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? sevenDayLatestFallback,
392
481
  sevenDayCumulativeUsagePct,
393
- uniqueSessions: day.uniqueSessions,
394
- uniqueWorkspaces: day.uniqueWorkspaces,
482
+ uniqueSessions,
483
+ uniqueWorkspaces,
395
484
  });
396
485
  }
397
486
  return rows.sort((left, right) => `${left.personKey}|${left.date}`.localeCompare(`${right.personKey}|${right.date}`));
@@ -409,46 +498,47 @@ function fallbackWeeklyUsage(days) {
409
498
  };
410
499
  }
411
500
  /**
412
- * 展开 weekly.csv:一个人同一周有多份 bundle(多台电脑各导出)时,不取单独一份的整周汇总,
413
- * 而是复用按天去重后的 daily winner(每天选有数据的那份),按 (person, 周) 把每天的 token / 计数累加上卷,
414
- * usage 从该周所有 winner 天的事件重算(peak 取 max、latest 取时间戳最新),缺失时回退到 daySummary 自带值。
501
+ * 展开 weekly.csv:不同机器的同人同天数据已在 selectDailyRepresentatives 层按 sessionId 去重分组,
502
+ * 这里直接按 (person, 周) 把所有代表的 token / 计数累加上卷,usage 从该周所有代表事件重算。
415
503
  */
416
504
  function buildAggregatedWeeklyRows(bundles) {
417
- const dailyWinners = selectDailyWinners(bundles);
505
+ const repsMap = selectDailyRepresentatives(bundles);
418
506
  const curves = buildPersonSevenDayCurve(bundles);
419
507
  const groups = new Map();
420
- for (const winner of dailyWinners.values()) {
421
- const week = weekKey(new Date(winner.bundle.range.start));
422
- const key = `${winner.personKey}|${week}`;
423
- let acc = groups.get(key);
424
- if (!acc) {
425
- acc = {
426
- personKey: winner.personKey,
427
- week,
428
- userMessageCount: 0,
429
- apiRequestCount: 0,
430
- inputTokens: 0,
431
- outputTokens: 0,
432
- cacheReadInputTokens: 0,
433
- sampleCount: 0,
434
- uniqueSessions: 0,
435
- uniqueWorkspaces: 0,
436
- days: [],
437
- events: [],
438
- };
439
- groups.set(key, acc);
508
+ for (const reps of repsMap.values()) {
509
+ for (const rep of reps) {
510
+ const week = weekKey(new Date(rep.bundle.range.start));
511
+ const key = `${rep.personKey}|${week}`;
512
+ let acc = groups.get(key);
513
+ if (!acc) {
514
+ acc = {
515
+ personKey: rep.personKey,
516
+ week,
517
+ userMessageCount: 0,
518
+ apiRequestCount: 0,
519
+ inputTokens: 0,
520
+ outputTokens: 0,
521
+ cacheReadInputTokens: 0,
522
+ sampleCount: 0,
523
+ uniqueSessions: 0,
524
+ uniqueWorkspaces: 0,
525
+ days: [],
526
+ events: [],
527
+ };
528
+ groups.set(key, acc);
529
+ }
530
+ const day = rep.day;
531
+ acc.userMessageCount += day.userMessageCount;
532
+ acc.apiRequestCount += day.apiRequestCount;
533
+ acc.inputTokens += day.inputTokens;
534
+ acc.outputTokens += day.outputTokens;
535
+ acc.cacheReadInputTokens += day.cacheReadInputTokens;
536
+ acc.sampleCount += day.sampleCount;
537
+ acc.uniqueSessions += day.uniqueSessions;
538
+ acc.uniqueWorkspaces += day.uniqueWorkspaces;
539
+ acc.days.push(day);
540
+ acc.events.push(...(bundleEventsByDate(rep.bundle).get(rep.date) ?? []));
440
541
  }
441
- const day = winner.day;
442
- acc.userMessageCount += day.userMessageCount;
443
- acc.apiRequestCount += day.apiRequestCount;
444
- acc.inputTokens += day.inputTokens;
445
- acc.outputTokens += day.outputTokens;
446
- acc.cacheReadInputTokens += day.cacheReadInputTokens;
447
- acc.sampleCount += day.sampleCount;
448
- acc.uniqueSessions += day.uniqueSessions;
449
- acc.uniqueWorkspaces += day.uniqueWorkspaces;
450
- acc.days.push(day);
451
- acc.events.push(...(bundleEventsByDate(winner.bundle).get(winner.date) ?? []));
452
542
  }
453
543
  const rows = [];
454
544
  for (const acc of groups.values()) {