claude-cup 0.4.1 → 0.5.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.
package/src/aggregator.js CHANGED
@@ -1,297 +1,317 @@
1
- // Aggregates parsed transcript events into "today" stats + persisted daily history.
2
- import { EventEmitter } from 'node:events';
3
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
- import { dirname } from 'node:path';
5
-
6
- // USD per million tokens (estimate; close enough for a jar).
7
- const PRICING = [
8
- { match: /opus/i, in: 5, out: 25 },
9
- { match: /sonnet/i, in: 3, out: 15 },
10
- { match: /haiku/i, in: 1, out: 5 },
11
- ];
12
- const DEFAULT_PRICE = { in: 3, out: 15 };
13
-
14
- export function priceFor(model) {
15
- for (const p of PRICING) if (p.match.test(model || '')) return p;
16
- return DEFAULT_PRICE;
17
- }
18
-
19
- export function costOf(model, usage) {
20
- const p = priceFor(model);
21
- const M = 1e6;
22
- return (
23
- (usage.in * p.in +
24
- usage.out * p.out +
25
- usage.cacheRead * p.in * 0.1 +
26
- usage.cacheW5m * p.in * 1.25 +
27
- usage.cacheW1h * p.in * 2) / M
28
- );
29
- }
30
-
31
- export function localDateKey(ts) {
32
- const d = new Date(ts);
33
- const mm = String(d.getMonth() + 1).padStart(2, '0');
34
- const dd = String(d.getDate()).padStart(2, '0');
35
- return `${d.getFullYear()}-${mm}-${dd}`;
36
- }
37
-
38
- export function localMidnight(now = Date.now()) {
39
- const d = new Date(now);
40
- d.setHours(0, 0, 0, 0);
41
- return d.getTime();
42
- }
43
-
44
- const EDIT_DEDUP_MS = 2 * 60 * 1000;
45
-
46
- function emptyDay(dateKey) {
47
- return {
48
- date: dateKey,
49
- tokensIn: 0,
50
- tokensOut: 0,
51
- cacheRead: 0,
52
- cacheWrite: 0,
53
- totalTokens: 0,
54
- cost: 0,
55
- toolCalls: 0,
56
- toolsByCategory: {},
57
- assistantMessages: 0,
58
- userPrompts: 0,
59
- models: {},
60
- fillMax: 0,
61
- _editEvents: [],
62
- _terminalCount: 0,
63
- _commitCount: 0,
64
- };
65
- }
66
-
67
- const BURN_WINDOW_MS = 10 * 60 * 1000;
68
-
69
- export class Aggregator extends EventEmitter {
70
- constructor({ historyPath, now = () => Date.now() } = {}) {
71
- super();
72
- this.historyPath = historyPath || null;
73
- this.now = now;
74
- this.day = emptyDay(localDateKey(this.now()));
75
- this.sessions = new Set();
76
- this.seen = new Set();
77
- this.histSeen = new Set();
78
- this.backfill = {}; // dateKey -> partial day totals from old transcripts
79
- this.burn = []; // {ts, tokens}
80
- this.history = { days: {} };
81
- this._saveTimer = null;
82
- this._loadHistory();
83
- }
84
-
85
- _loadHistory() {
86
- if (!this.historyPath) return;
87
- try {
88
- const parsed = JSON.parse(readFileSync(this.historyPath, 'utf8'));
89
- if (parsed && typeof parsed.days === 'object') this.history = parsed;
90
- } catch {
91
- /* first run */
92
- }
93
- // Restore today's stats if the server restarted mid-day.
94
- const saved = this.history.days[this.day.date];
95
- if (saved && saved._full) {
96
- this.day = { ...emptyDay(this.day.date), ...saved._full };
97
- this.sessions = new Set(saved._full.sessionIds || []);
98
- this.seen = new Set(saved._full.seenUuids || []);
99
- }
100
- }
101
-
102
- _rolloverIfNeeded() {
103
- const key = localDateKey(this.now());
104
- if (key !== this.day.date) {
105
- this._snapshotToHistory();
106
- this.day = emptyDay(key);
107
- this.sessions = new Set();
108
- this.seen = new Set();
109
- this.burn = [];
110
- this.emit('rollover', key);
111
- }
112
- }
113
-
114
- _snapshotToHistory() {
115
- const d = this.day;
116
- this.history.days[d.date] = {
117
- totalTokens: d.totalTokens,
118
- cost: Math.round(d.cost * 10000) / 10000,
119
- toolCalls: d.toolCalls,
120
- messages: d.assistantMessages,
121
- fillMax: d.fillMax,
122
- _full: {
123
- ...d,
124
- sessionIds: [...this.sessions].slice(0, 500),
125
- seenUuids: [...this.seen].slice(-5000),
126
- },
127
- };
128
- }
129
-
130
- saveNow() {
131
- if (!this.historyPath) return;
132
- this._snapshotToHistory();
133
- try {
134
- mkdirSync(dirname(this.historyPath), { recursive: true });
135
- writeFileSync(this.historyPath, JSON.stringify(this.history));
136
- } catch {
137
- /* non-fatal */
138
- }
139
- }
140
-
141
- _saveSoon() {
142
- if (!this.historyPath || this._saveTimer) return;
143
- this._saveTimer = setTimeout(() => {
144
- this._saveTimer = null;
145
- this.saveNow();
146
- }, 5000);
147
- this._saveTimer.unref?.();
148
- }
149
-
150
- noteFill(pct) {
151
- this._rolloverIfNeeded();
152
- if (typeof pct === 'number' && pct > this.day.fillMax) {
153
- this.day.fillMax = Math.min(100, Math.round(pct * 10) / 10);
154
- this._saveSoon();
155
- }
156
- }
157
-
158
- /** Accumulates an event from a previous day into the history shelf. */
159
- _addBackfill(evt) {
160
- if (evt.kind !== 'assistant') return;
161
- if (evt.uuid) {
162
- if (this.histSeen.has(evt.uuid)) return;
163
- this.histSeen.add(evt.uuid);
164
- }
165
- const key = localDateKey(evt.ts);
166
- const b = (this.backfill[key] ||= { totalTokens: 0, cost: 0, toolCalls: 0, messages: 0, fillMax: 0 });
167
- const u = evt.usage;
168
- b.totalTokens += u.in + u.out;
169
- b.cost += costOf(evt.model, u);
170
- b.toolCalls += evt.tools.length;
171
- b.messages += 1;
172
- }
173
-
174
- /** @returns {boolean} true if the event counted toward today (and may animate) */
175
- addEvent(evt) {
176
- if (!evt) return false;
177
- this._rolloverIfNeeded();
178
- if (evt.ts > this.now() + 60_000) return false;
179
- if (evt.ts < localMidnight(this.now())) {
180
- if (evt.ts >= localMidnight(this.now()) - 7 * 86400000) this._addBackfill(evt);
181
- return false;
182
- }
183
- if (evt.uuid) {
184
- if (this.seen.has(evt.uuid)) return false;
185
- this.seen.add(evt.uuid);
186
- }
187
- if (evt.sessionId) this.sessions.add(evt.sessionId);
188
- const d = this.day;
189
-
190
- if (evt.kind === 'prompt') {
191
- d.userPrompts += 1;
192
- this._saveSoon();
193
- return true;
194
- }
195
-
196
- if (evt.kind === 'assistant') {
197
- const u = evt.usage;
198
- const fresh = u.in + u.out;
199
- d.tokensIn += u.in;
200
- d.tokensOut += u.out;
201
- d.cacheRead += u.cacheRead;
202
- d.cacheWrite += u.cacheW5m + u.cacheW1h;
203
- d.totalTokens += fresh;
204
- d.cost += costOf(evt.model, u);
205
- d.assistantMessages += 1;
206
- d.models[evt.model] = (d.models[evt.model] || 0) + fresh;
207
- let evtEdits = 0;
208
- let evtTerminals = 0;
209
- for (const t of evt.tools) {
210
- d.toolCalls += 1;
211
- d.toolsByCategory[t.category] = (d.toolsByCategory[t.category] || 0) + 1;
212
- if (t.category === 'edit') evtEdits++;
213
- if (t.category === 'terminal') evtTerminals++;
214
- }
215
- if (evtEdits > 0) d._editEvents.push({ ts: evt.ts, count: evtEdits });
216
- d._terminalCount += evtTerminals;
217
- if (fresh > 0) {
218
- this.burn.push({ ts: evt.ts, tokens: fresh });
219
- }
220
- this._saveSoon();
221
- return true;
222
- }
223
- return false;
224
- }
225
-
226
- burnRate() {
227
- const cutoff = this.now() - BURN_WINDOW_MS;
228
- while (this.burn.length && this.burn[0].ts < cutoff) this.burn.shift();
229
- const total = this.burn.reduce((a, b) => a + b.tokens, 0);
230
- return Math.round(total / (BURN_WINDOW_MS / 60000));
231
- }
232
-
233
- _netEdits() {
234
- const edits = this.day._editEvents;
235
- if (!edits.length) return 0;
236
- let net = 0;
237
- for (let i = 0; i < edits.length; i++) {
238
- const next = edits[i + 1];
239
- if (next && (next.ts - edits[i].ts) < EDIT_DEDUP_MS) continue;
240
- net += edits[i].count;
241
- }
242
- return net;
243
- }
244
-
245
- buildRate() {
246
- const d = this.day;
247
- const net = this._netEdits();
248
- const kiloTokens = Math.max(1, d.totalTokens / 1000);
249
- return Math.round(((net + d._terminalCount + d._commitCount * 5) / kiloTokens) * 100) / 100;
250
- }
251
-
252
- snapshot() {
253
- this._rolloverIfNeeded();
254
- const d = this.day;
255
- return {
256
- date: d.date,
257
- tokensIn: d.tokensIn,
258
- tokensOut: d.tokensOut,
259
- cacheRead: d.cacheRead,
260
- cacheWrite: d.cacheWrite,
261
- totalTokens: d.totalTokens,
262
- cost: Math.round(d.cost * 100) / 100,
263
- toolCalls: d.toolCalls,
264
- toolsByCategory: d.toolsByCategory,
265
- assistantMessages: d.assistantMessages,
266
- userPrompts: d.userPrompts,
267
- sessions: this.sessions.size,
268
- models: d.models,
269
- burnRate: this.burnRate(),
270
- fillMax: d.fillMax,
271
- buildRate: this.buildRate(),
272
- netEdits: this._netEdits(),
273
- cleanTerminal: d._terminalCount,
274
- commits: d._commitCount,
275
- };
276
- }
277
-
278
- /** last N days (excluding today), oldest first. Live records win over backfill. */
279
- historyDays(n = 7) {
280
- const out = [];
281
- for (let i = n; i >= 1; i--) {
282
- const key = localDateKey(this.now() - i * 86400000);
283
- const day = this.history.days[key]?._full ? this.history.days[key] : null;
284
- const fallback = this.backfill[key] || this.history.days[key];
285
- const d = day || fallback;
286
- out.push({
287
- date: key,
288
- totalTokens: d?.totalTokens || 0,
289
- cost: Math.round((d?.cost || 0) * 100) / 100,
290
- toolCalls: d?.toolCalls || 0,
291
- messages: d?.messages || 0,
292
- fillMax: d?.fillMax || 0,
293
- });
294
- }
295
- return out;
296
- }
297
- }
1
+ // Aggregates parsed transcript events into "today" stats + persisted daily history.
2
+ import { EventEmitter } from 'node:events';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+
6
+ // USD per million tokens (estimate; close enough for a jar).
7
+ const PRICING = [
8
+ { match: /opus/i, in: 5, out: 25 },
9
+ { match: /sonnet/i, in: 3, out: 15 },
10
+ { match: /haiku/i, in: 1, out: 5 },
11
+ ];
12
+ const DEFAULT_PRICE = { in: 3, out: 15 };
13
+
14
+ export function priceFor(model) {
15
+ for (const p of PRICING) if (p.match.test(model || '')) return p;
16
+ return DEFAULT_PRICE;
17
+ }
18
+
19
+ export function costOf(model, usage) {
20
+ const p = priceFor(model);
21
+ const M = 1e6;
22
+ return (
23
+ (usage.in * p.in +
24
+ usage.out * p.out +
25
+ usage.cacheRead * p.in * 0.1 +
26
+ usage.cacheW5m * p.in * 1.25 +
27
+ usage.cacheW1h * p.in * 2) / M
28
+ );
29
+ }
30
+
31
+ export function localDateKey(ts) {
32
+ const d = new Date(ts);
33
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
34
+ const dd = String(d.getDate()).padStart(2, '0');
35
+ return `${d.getFullYear()}-${mm}-${dd}`;
36
+ }
37
+
38
+ export function localMidnight(now = Date.now()) {
39
+ const d = new Date(now);
40
+ d.setHours(0, 0, 0, 0);
41
+ return d.getTime();
42
+ }
43
+
44
+ const EDIT_DEDUP_MS = 2 * 60 * 1000;
45
+
46
+ function emptyDay(dateKey) {
47
+ return {
48
+ date: dateKey,
49
+ tokensIn: 0,
50
+ tokensOut: 0,
51
+ cacheRead: 0,
52
+ cacheWrite: 0,
53
+ totalTokens: 0,
54
+ cost: 0,
55
+ toolCalls: 0,
56
+ toolsByCategory: {},
57
+ assistantMessages: 0,
58
+ userPrompts: 0,
59
+ models: {},
60
+ fillMax: 0,
61
+ _editEvents: [],
62
+ _terminalCount: 0,
63
+ _commitCount: 0,
64
+ };
65
+ }
66
+
67
+ const BURN_WINDOW_MS = 10 * 60 * 1000;
68
+
69
+ export class Aggregator extends EventEmitter {
70
+ constructor({ historyPath, now = () => Date.now() } = {}) {
71
+ super();
72
+ this.historyPath = historyPath || null;
73
+ this.now = now;
74
+ this.day = emptyDay(localDateKey(this.now()));
75
+ this.sessions = new Set();
76
+ this.seen = new Set();
77
+ this.histSeen = new Set();
78
+ this.backfill = {}; // dateKey -> partial day totals from old transcripts
79
+ this.burn = []; // {ts, tokens}
80
+ this.history = { days: {} };
81
+ this._saveTimer = null;
82
+ this._loadHistory();
83
+ }
84
+
85
+ _loadHistory() {
86
+ if (!this.historyPath) return;
87
+ try {
88
+ const parsed = JSON.parse(readFileSync(this.historyPath, 'utf8'));
89
+ if (parsed && typeof parsed.days === 'object') this.history = parsed;
90
+ } catch {
91
+ /* first run */
92
+ }
93
+ // Restore today's stats if the server restarted mid-day.
94
+ const saved = this.history.days[this.day.date];
95
+ if (saved && saved._full) {
96
+ this.day = { ...emptyDay(this.day.date), ...saved._full };
97
+ this.sessions = new Set(saved._full.sessionIds || []);
98
+ this.seen = new Set(saved._full.seenUuids || []);
99
+ }
100
+ }
101
+
102
+ _rolloverIfNeeded() {
103
+ const key = localDateKey(this.now());
104
+ if (key !== this.day.date) {
105
+ this._snapshotToHistory();
106
+ this.day = emptyDay(key);
107
+ this.sessions = new Set();
108
+ this.seen = new Set();
109
+ this.burn = [];
110
+ this.emit('rollover', key);
111
+ }
112
+ }
113
+
114
+ _snapshotToHistory() {
115
+ const d = this.day;
116
+ this.history.days[d.date] = {
117
+ totalTokens: d.totalTokens,
118
+ cost: Math.round(d.cost * 10000) / 10000,
119
+ toolCalls: d.toolCalls,
120
+ messages: d.assistantMessages,
121
+ fillMax: d.fillMax,
122
+ buildRate: this.buildRate(),
123
+ _full: {
124
+ ...d,
125
+ sessionIds: [...this.sessions].slice(0, 500),
126
+ seenUuids: [...this.seen].slice(-5000),
127
+ },
128
+ };
129
+ }
130
+
131
+ /** Weighted 7-day rolling Build Rate: today=1.0, then 0.9, 0.8, ... 0.4 */
132
+ rollingBuildRate() {
133
+ const weights = [0.4, 0.5, 0.6, 0.7, 0.8, 0.9]; // oldest first, today added below
134
+ const past = this.historyDays(6); // 6 prior days, oldest first
135
+ let weightedSum = 0;
136
+ let weightTotal = 0;
137
+ past.forEach((day, i) => {
138
+ const w = weights[i] || 0.4;
139
+ const br = day.buildRate || 0;
140
+ weightedSum += br * w;
141
+ weightTotal += w;
142
+ });
143
+ const todayBr = this.buildRate();
144
+ weightedSum += todayBr * 1.0;
145
+ weightTotal += 1.0;
146
+ return Math.round((weightedSum / weightTotal) * 100) / 100;
147
+ }
148
+
149
+ saveNow() {
150
+ if (!this.historyPath) return;
151
+ this._snapshotToHistory();
152
+ try {
153
+ mkdirSync(dirname(this.historyPath), { recursive: true });
154
+ writeFileSync(this.historyPath, JSON.stringify(this.history));
155
+ } catch {
156
+ /* non-fatal */
157
+ }
158
+ }
159
+
160
+ _saveSoon() {
161
+ if (!this.historyPath || this._saveTimer) return;
162
+ this._saveTimer = setTimeout(() => {
163
+ this._saveTimer = null;
164
+ this.saveNow();
165
+ }, 5000);
166
+ this._saveTimer.unref?.();
167
+ }
168
+
169
+ noteFill(pct) {
170
+ this._rolloverIfNeeded();
171
+ if (typeof pct === 'number' && pct > this.day.fillMax) {
172
+ this.day.fillMax = Math.min(100, Math.round(pct * 10) / 10);
173
+ this._saveSoon();
174
+ }
175
+ }
176
+
177
+ /** Accumulates an event from a previous day into the history shelf. */
178
+ _addBackfill(evt) {
179
+ if (evt.kind !== 'assistant') return;
180
+ if (evt.uuid) {
181
+ if (this.histSeen.has(evt.uuid)) return;
182
+ this.histSeen.add(evt.uuid);
183
+ }
184
+ const key = localDateKey(evt.ts);
185
+ const b = (this.backfill[key] ||= { totalTokens: 0, cost: 0, toolCalls: 0, messages: 0, fillMax: 0 });
186
+ const u = evt.usage;
187
+ b.totalTokens += u.in + u.out;
188
+ b.cost += costOf(evt.model, u);
189
+ b.toolCalls += evt.tools.length;
190
+ b.messages += 1;
191
+ }
192
+
193
+ /** @returns {boolean} true if the event counted toward today (and may animate) */
194
+ addEvent(evt) {
195
+ if (!evt) return false;
196
+ this._rolloverIfNeeded();
197
+ if (evt.ts > this.now() + 60_000) return false;
198
+ if (evt.ts < localMidnight(this.now())) {
199
+ if (evt.ts >= localMidnight(this.now()) - 7 * 86400000) this._addBackfill(evt);
200
+ return false;
201
+ }
202
+ if (evt.uuid) {
203
+ if (this.seen.has(evt.uuid)) return false;
204
+ this.seen.add(evt.uuid);
205
+ }
206
+ if (evt.sessionId) this.sessions.add(evt.sessionId);
207
+ const d = this.day;
208
+
209
+ if (evt.kind === 'prompt') {
210
+ d.userPrompts += 1;
211
+ this._saveSoon();
212
+ return true;
213
+ }
214
+
215
+ if (evt.kind === 'assistant') {
216
+ const u = evt.usage;
217
+ const fresh = u.in + u.out;
218
+ d.tokensIn += u.in;
219
+ d.tokensOut += u.out;
220
+ d.cacheRead += u.cacheRead;
221
+ d.cacheWrite += u.cacheW5m + u.cacheW1h;
222
+ d.totalTokens += fresh;
223
+ d.cost += costOf(evt.model, u);
224
+ d.assistantMessages += 1;
225
+ d.models[evt.model] = (d.models[evt.model] || 0) + fresh;
226
+ let evtEdits = 0;
227
+ let evtTerminals = 0;
228
+ for (const t of evt.tools) {
229
+ d.toolCalls += 1;
230
+ d.toolsByCategory[t.category] = (d.toolsByCategory[t.category] || 0) + 1;
231
+ if (t.category === 'edit') evtEdits++;
232
+ if (t.category === 'terminal') evtTerminals++;
233
+ }
234
+ if (evtEdits > 0) d._editEvents.push({ ts: evt.ts, count: evtEdits });
235
+ d._terminalCount += evtTerminals;
236
+ if (fresh > 0) {
237
+ this.burn.push({ ts: evt.ts, tokens: fresh });
238
+ }
239
+ this._saveSoon();
240
+ return true;
241
+ }
242
+ return false;
243
+ }
244
+
245
+ burnRate() {
246
+ const cutoff = this.now() - BURN_WINDOW_MS;
247
+ while (this.burn.length && this.burn[0].ts < cutoff) this.burn.shift();
248
+ const total = this.burn.reduce((a, b) => a + b.tokens, 0);
249
+ return Math.round(total / (BURN_WINDOW_MS / 60000));
250
+ }
251
+
252
+ _netEdits() {
253
+ const edits = this.day._editEvents;
254
+ if (!edits.length) return 0;
255
+ let net = 0;
256
+ for (let i = 0; i < edits.length; i++) {
257
+ const next = edits[i + 1];
258
+ if (next && (next.ts - edits[i].ts) < EDIT_DEDUP_MS) continue;
259
+ net += edits[i].count;
260
+ }
261
+ return net;
262
+ }
263
+
264
+ buildRate() {
265
+ const d = this.day;
266
+ const net = this._netEdits();
267
+ const kiloTokens = Math.max(1, d.totalTokens / 1000);
268
+ return Math.round(((net + d._terminalCount + d._commitCount * 5) / kiloTokens) * 100) / 100;
269
+ }
270
+
271
+ snapshot() {
272
+ this._rolloverIfNeeded();
273
+ const d = this.day;
274
+ return {
275
+ date: d.date,
276
+ tokensIn: d.tokensIn,
277
+ tokensOut: d.tokensOut,
278
+ cacheRead: d.cacheRead,
279
+ cacheWrite: d.cacheWrite,
280
+ totalTokens: d.totalTokens,
281
+ cost: Math.round(d.cost * 100) / 100,
282
+ toolCalls: d.toolCalls,
283
+ toolsByCategory: d.toolsByCategory,
284
+ assistantMessages: d.assistantMessages,
285
+ userPrompts: d.userPrompts,
286
+ sessions: this.sessions.size,
287
+ models: d.models,
288
+ burnRate: this.burnRate(),
289
+ fillMax: d.fillMax,
290
+ buildRate: this.buildRate(),
291
+ netEdits: this._netEdits(),
292
+ cleanTerminal: d._terminalCount,
293
+ commits: d._commitCount,
294
+ };
295
+ }
296
+
297
+ /** last N days (excluding today), oldest first. Live records win over backfill. */
298
+ historyDays(n = 7) {
299
+ const out = [];
300
+ for (let i = n; i >= 1; i--) {
301
+ const key = localDateKey(this.now() - i * 86400000);
302
+ const day = this.history.days[key]?._full ? this.history.days[key] : null;
303
+ const fallback = this.backfill[key] || this.history.days[key];
304
+ const d = day || fallback;
305
+ out.push({
306
+ date: key,
307
+ totalTokens: d?.totalTokens || 0,
308
+ cost: Math.round((d?.cost || 0) * 100) / 100,
309
+ toolCalls: d?.toolCalls || 0,
310
+ messages: d?.messages || 0,
311
+ fillMax: d?.fillMax || 0,
312
+ buildRate: d?.buildRate || 0,
313
+ });
314
+ }
315
+ return out;
316
+ }
317
+ }
package/src/cli.js CHANGED
@@ -23,7 +23,7 @@ import { openDb, insertEvent, upsertCurrentSession, getCurrentSession } from '..
23
23
  import { registerClaudeCode, registerCursorIfPresent, getRegistrationRecordPath } from '../mcp-server/src/registration.js';
24
24
  import { runCalibration } from '../mcp-server/src/calibrator.js';
25
25
  import { computeSessionFingerprint, saveFingerprint } from '../mcp-server/src/fingerprint.js';
26
- import { submitAndRank, getCachedRank } from './leaderboard.js';
26
+ import { submitAndRank, getCachedRank, nextRefreshMs } from './leaderboard.js';
27
27
 
28
28
 
29
29
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -290,20 +290,29 @@ Options:
290
290
 
291
291
  const getPower = () => ({ powerLevel: currentPower, richness: currentRichness });
292
292
 
293
- // --- Leaderboard: submit Build Rate and get rank ---
293
+ // --- Leaderboard: submit rolling Build Rate and get rank ---
294
+ // Adaptive cadence: 2 min when BR is actively changing, 5 min when idle.
294
295
  let currentLeaderboard = getCachedRank();
295
- const refreshLeaderboard = async () => {
296
+ let lbTimer = null;
297
+ const scheduleLb = (delayMs) => {
298
+ if (lbTimer) clearTimeout(lbTimer);
299
+ lbTimer = setTimeout(runLb, delayMs);
300
+ lbTimer.unref?.();
301
+ };
302
+ const runLb = async () => {
296
303
  try {
297
- const snap = aggregator.snapshot();
298
- if (snap.buildRate > 0) {
299
- currentLeaderboard = await submitAndRank(snap.buildRate);
304
+ const rollingBR = aggregator.rollingBuildRate();
305
+ if (rollingBR > 0) {
306
+ const prev = currentLeaderboard;
307
+ currentLeaderboard = await submitAndRank(rollingBR);
300
308
  setLeaderboard(currentLeaderboard);
309
+ scheduleLb(nextRefreshMs(prev, currentLeaderboard));
310
+ return;
301
311
  }
302
312
  } catch { /* non-fatal */ }
313
+ scheduleLb(5 * 60_000);
303
314
  };
304
- setTimeout(refreshLeaderboard, 8000);
305
- const lbTimer = setInterval(refreshLeaderboard, 5 * 60_000);
306
- lbTimer.unref?.();
315
+ scheduleLb(8000);
307
316
  const getLeaderboard = () => currentLeaderboard;
308
317
 
309
318
  const port = await listen(server, args.port);