claude-token-saver 2.0.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/stats.js ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Aggregate session data into daily trends, TTL breakdown, and anomalies.
3
+ */
4
+
5
+ function dateKey(date) {
6
+ return date.toISOString().slice(0, 10);
7
+ }
8
+
9
+ function hitRate(read, creation, input) {
10
+ const total = read + creation + input;
11
+ return total > 0 ? read / total : 0;
12
+ }
13
+
14
+ /**
15
+ * Daily cache hit rate trend
16
+ */
17
+ export function dailyTrend(sessions) {
18
+ const byDay = new Map();
19
+
20
+ for (const s of sessions) {
21
+ if (!s.startTime) continue;
22
+ const day = dateKey(s.startTime);
23
+ if (!byDay.has(day)) {
24
+ byDay.set(day, {
25
+ date: day,
26
+ input: 0,
27
+ cacheCreation: 0,
28
+ cacheRead: 0,
29
+ ephemeral5m: 0,
30
+ ephemeral1h: 0,
31
+ output: 0,
32
+ apiCalls: 0,
33
+ sessions: 0,
34
+ });
35
+ }
36
+ const d = byDay.get(day);
37
+ d.input += s.totals.input;
38
+ d.cacheCreation += s.totals.cacheCreation;
39
+ d.cacheRead += s.totals.cacheRead;
40
+ d.ephemeral5m += s.totals.ephemeral5m;
41
+ d.ephemeral1h += s.totals.ephemeral1h;
42
+ d.output += s.totals.output;
43
+ d.apiCalls += s.requestCount;
44
+ d.sessions += 1;
45
+ }
46
+
47
+ return [...byDay.values()]
48
+ .sort((a, b) => a.date.localeCompare(b.date))
49
+ .map((d) => ({
50
+ ...d,
51
+ hitRate: hitRate(d.cacheRead, d.cacheCreation, d.input),
52
+ totalInput: d.cacheRead + d.cacheCreation + d.input,
53
+ }));
54
+ }
55
+
56
+ /**
57
+ * TTL breakdown summary
58
+ */
59
+ export function ttlBreakdown(sessions) {
60
+ let total5m = 0;
61
+ let total1h = 0;
62
+
63
+ for (const s of sessions) {
64
+ total5m += s.totals.ephemeral5m;
65
+ total1h += s.totals.ephemeral1h;
66
+ }
67
+
68
+ const total = total5m + total1h;
69
+ return {
70
+ ephemeral5m: total5m,
71
+ ephemeral1h: total1h,
72
+ total,
73
+ pct5m: total > 0 ? total5m / total : 0,
74
+ pct1h: total > 0 ? total1h / total : 0,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Detect anomalies — days where hit rate drops significantly from rolling average
80
+ */
81
+ export function detectAnomalies(trend, { threshold = 0.15 } = {}) {
82
+ const anomalies = [];
83
+ const windowSize = 7;
84
+
85
+ for (let i = 0; i < trend.length; i++) {
86
+ const day = trend[i];
87
+ if (day.apiCalls < 5) continue; // skip low-volume days
88
+
89
+ // rolling average of prior days
90
+ const windowStart = Math.max(0, i - windowSize);
91
+ const window = trend.slice(windowStart, i);
92
+ if (window.length < 3) continue;
93
+
94
+ const avgHitRate =
95
+ window.reduce((sum, d) => sum + d.hitRate, 0) / window.length;
96
+
97
+ const drop = avgHitRate - day.hitRate;
98
+ if (drop > threshold) {
99
+ anomalies.push({
100
+ date: day.date,
101
+ hitRate: day.hitRate,
102
+ avgHitRate,
103
+ drop,
104
+ apiCalls: day.apiCalls,
105
+ });
106
+ }
107
+ }
108
+
109
+ return anomalies;
110
+ }
111
+
112
+ /**
113
+ * Per-session metrics used by diagnostics.
114
+ */
115
+ export function sessionMetrics(session) {
116
+ const t = session.totals;
117
+ const totalInput = t.input + t.cacheCreation + t.cacheRead;
118
+ const reqs = session.requestCount || 0;
119
+ const avgInputPerReq = reqs > 0 ? totalInput / reqs : 0;
120
+ const hit = hitRate(t.cacheRead, t.cacheCreation, t.input);
121
+ const ttlSum = t.ephemeral5m + t.ephemeral1h;
122
+ const pct5m = ttlSum > 0 ? t.ephemeral5m / ttlSum : 0;
123
+ const outputRatio = totalInput > 0 ? t.output / totalInput : 0;
124
+ const writeToReadRatio = t.cacheRead > 0 ? t.cacheCreation / t.cacheRead : (t.cacheCreation > 0 ? Infinity : 0);
125
+ return {
126
+ sessionId: session.sessionId,
127
+ projectDir: session.projectDir,
128
+ startTime: session.startTime,
129
+ endTime: session.endTime,
130
+ requestCount: reqs,
131
+ totalInput,
132
+ avgInputPerReq,
133
+ hitRate: hit,
134
+ pct5m,
135
+ outputRatio,
136
+ writeToReadRatio,
137
+ maxContextPerRequest: session.maxContextPerRequest || 0,
138
+ totals: t,
139
+ };
140
+ }
141
+
142
+ function median(values) {
143
+ if (values.length === 0) return 0;
144
+ const sorted = [...values].sort((a, b) => a - b);
145
+ const mid = Math.floor(sorted.length / 2);
146
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
147
+ }
148
+
149
+ function percentile(values, p) {
150
+ if (values.length === 0) return 0;
151
+ const sorted = [...values].sort((a, b) => a - b);
152
+ const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
153
+ return sorted[idx];
154
+ }
155
+
156
+ /**
157
+ * Baseline using sessions OUTSIDE the recent window. Recent sessions are what
158
+ * we're diagnosing — if we let them into the baseline they'd drag the baseline
159
+ * toward themselves and never register as anomalies.
160
+ */
161
+ export function computeBaseline(sessions, recentWindowMs = 24 * 60 * 60 * 1000) {
162
+ const cutoff = Date.now() - recentWindowMs;
163
+ const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount > 0);
164
+ if (older.length < 3) {
165
+ return { enough: false, sampleSize: older.length };
166
+ }
167
+ const metrics = older.map(sessionMetrics);
168
+ return {
169
+ enough: true,
170
+ sampleSize: older.length,
171
+ medianAvgInputPerReq: median(metrics.map((m) => m.avgInputPerReq)),
172
+ p95AvgInputPerReq: percentile(metrics.map((m) => m.avgInputPerReq), 0.95),
173
+ medianTotalInput: median(metrics.map((m) => m.totalInput)),
174
+ p95TotalInput: percentile(metrics.map((m) => m.totalInput), 0.95),
175
+ medianHitRate: median(metrics.map((m) => m.hitRate)),
176
+ medianRequestCount: median(metrics.map((m) => m.requestCount)),
177
+ p95RequestCount: percentile(metrics.map((m) => m.requestCount), 0.95),
178
+ medianMaxContext: median(metrics.map((m) => m.maxContextPerRequest)),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Diagnose one session against a baseline. Returns issue codes + supporting
184
+ * numbers so the formatter can render human-readable messages without
185
+ * re-computing anything.
186
+ *
187
+ * Issue codes:
188
+ * LARGE_INPUT_PER_REQUEST — likely 1M context mode; avg input per request
189
+ * is 8x+ baseline or max req context > 250k
190
+ * LOW_HIT_RATE — below 0.5 and baseline was meaningfully higher
191
+ * BUCKET_5M_DOMINANT — 5m TTL writes dominate (>70%); prefix re-writes
192
+ * HIGH_OUTPUT_RATIO — output/input ratio > 0.15 (unusually chatty)
193
+ * HIGH_REQUEST_COUNT — request count > 3x baseline
194
+ * FREQUENT_CACHE_REBUILD — cacheCreation > cacheRead (cache not reused)
195
+ */
196
+ export function diagnoseSession(metrics, baseline) {
197
+ const issues = [];
198
+ if (!metrics || metrics.requestCount === 0) return issues;
199
+
200
+ const b = baseline?.enough ? baseline : null;
201
+
202
+ if (metrics.maxContextPerRequest > 250_000 ||
203
+ (b && b.medianAvgInputPerReq > 0 && metrics.avgInputPerReq > b.medianAvgInputPerReq * 8)) {
204
+ issues.push({
205
+ code: 'LARGE_INPUT_PER_REQUEST',
206
+ avgInputPerReq: metrics.avgInputPerReq,
207
+ maxContextPerRequest: metrics.maxContextPerRequest,
208
+ baseline: b?.medianAvgInputPerReq,
209
+ });
210
+ }
211
+
212
+ if (metrics.hitRate < 0.5 && (!b || b.medianHitRate > metrics.hitRate + 0.2)) {
213
+ issues.push({
214
+ code: 'LOW_HIT_RATE',
215
+ hitRate: metrics.hitRate,
216
+ baseline: b?.medianHitRate,
217
+ });
218
+ }
219
+
220
+ if (metrics.pct5m > 0.7 && (metrics.totals.ephemeral5m + metrics.totals.ephemeral1h) > 0) {
221
+ issues.push({
222
+ code: 'BUCKET_5M_DOMINANT',
223
+ pct5m: metrics.pct5m,
224
+ });
225
+ }
226
+
227
+ if (metrics.outputRatio > 0.15) {
228
+ issues.push({
229
+ code: 'HIGH_OUTPUT_RATIO',
230
+ outputRatio: metrics.outputRatio,
231
+ });
232
+ }
233
+
234
+ if (b && metrics.requestCount > b.medianRequestCount * 3 && metrics.requestCount > 30) {
235
+ issues.push({
236
+ code: 'HIGH_REQUEST_COUNT',
237
+ requestCount: metrics.requestCount,
238
+ baseline: b.medianRequestCount,
239
+ });
240
+ }
241
+
242
+ if (metrics.writeToReadRatio !== Infinity &&
243
+ metrics.writeToReadRatio > 1 &&
244
+ metrics.totals.cacheCreation > 100_000) {
245
+ issues.push({
246
+ code: 'FREQUENT_CACHE_REBUILD',
247
+ writeToReadRatio: metrics.writeToReadRatio,
248
+ });
249
+ }
250
+
251
+ return issues;
252
+ }
253
+
254
+ /**
255
+ * Find sessions in the recent window whose token totals are >= multiplier x
256
+ * the baseline p95. Returns spikes sorted by severity (largest first) with
257
+ * diagnosis attached.
258
+ */
259
+ export function detectSpikes(sessions, { recentHours = 24, multiplier = 3 } = {}) {
260
+ const baseline = computeBaseline(sessions, recentHours * 60 * 60 * 1000);
261
+ const cutoff = Date.now() - recentHours * 60 * 60 * 1000;
262
+ const recent = sessions.filter(
263
+ (s) => s.startTime && s.startTime.getTime() >= cutoff && s.requestCount > 0,
264
+ );
265
+
266
+ const spikes = [];
267
+ for (const s of recent) {
268
+ const m = sessionMetrics(s);
269
+ // Need a floor so tiny sessions with a few hundred tokens don't register
270
+ // as spikes just because baseline is also small.
271
+ if (m.totalInput < 1_000_000) continue;
272
+
273
+ const ratio = baseline.enough && baseline.p95TotalInput > 0
274
+ ? m.totalInput / baseline.p95TotalInput
275
+ : null;
276
+
277
+ const isSpike =
278
+ (ratio !== null && ratio >= multiplier) ||
279
+ m.maxContextPerRequest > 250_000;
280
+
281
+ if (!isSpike) continue;
282
+
283
+ spikes.push({
284
+ metrics: m,
285
+ ratio,
286
+ issues: diagnoseSession(m, baseline),
287
+ });
288
+ }
289
+
290
+ spikes.sort((a, b) => b.metrics.totalInput - a.metrics.totalInput);
291
+ return { baseline, spikes };
292
+ }
293
+
294
+ /**
295
+ * Detect the likely context-window setting from the largest single-request
296
+ * context seen in the recent window. Claude Code's two modes are 200k (default)
297
+ * and 1M (Opus 4.7+ auto-enabled on Max). If max single-request context passes
298
+ * the 200k ceiling, the user has 1M turned on.
299
+ *
300
+ * Returns { size: '1M' | '200k' | 'unknown', maxContext, source }.
301
+ */
302
+ export function detectContextWindow(sessions, { recentHours = 24 } = {}) {
303
+ const cutoff = Date.now() - recentHours * 60 * 60 * 1000;
304
+ const recent = sessions.filter(
305
+ (s) => s.endTime && s.endTime.getTime() >= cutoff && s.requestCount > 0,
306
+ );
307
+ const pool = recent.length > 0 ? recent : sessions;
308
+ const maxContext = pool.reduce(
309
+ (m, s) => Math.max(m, s.maxContextPerRequest || 0),
310
+ 0,
311
+ );
312
+ if (maxContext === 0) return { size: 'unknown', maxContext, source: 'no-data' };
313
+ // 200_000 is the hard ceiling for the standard window. Anything materially
314
+ // over that means the 1M context is in play. Use 210k for a small safety
315
+ // margin against rounding/metadata tokens.
316
+ if (maxContext > 210_000) return { size: '1M', maxContext, source: recent.length > 0 ? 'recent' : 'all' };
317
+ return { size: '200k', maxContext, source: recent.length > 0 ? 'recent' : 'all' };
318
+ }
319
+
320
+ /**
321
+ * Overall summary
322
+ */
323
+ export function summary(sessions) {
324
+ const totals = sessions.reduce(
325
+ (acc, s) => {
326
+ acc.input += s.totals.input;
327
+ acc.cacheCreation += s.totals.cacheCreation;
328
+ acc.cacheRead += s.totals.cacheRead;
329
+ acc.ephemeral5m += s.totals.ephemeral5m;
330
+ acc.ephemeral1h += s.totals.ephemeral1h;
331
+ acc.output += s.totals.output;
332
+ acc.apiCalls += s.requestCount;
333
+ acc.sessions += 1;
334
+ return acc;
335
+ },
336
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0, apiCalls: 0, sessions: 0 },
337
+ );
338
+
339
+ const totalInput = totals.cacheRead + totals.cacheCreation + totals.input;
340
+
341
+ return {
342
+ ...totals,
343
+ totalInput,
344
+ hitRate: hitRate(totals.cacheRead, totals.cacheCreation, totals.input),
345
+ };
346
+ }