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/README.md +346 -0
- package/bin/cli.js +228 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/examples/statusline-with-rz1989s.sh +52 -0
- package/package.json +37 -0
- package/src/advice.js +138 -0
- package/src/cost.js +150 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +164 -0
- package/src/formatters/table.js +235 -0
- package/src/hook-manager.js +89 -0
- package/src/hook.cjs +170 -0
- package/src/parser.js +197 -0
- package/src/stats.js +346 -0
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
|
+
}
|