claude-token-saver 2.0.3 → 2.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.
- package/README.md +49 -0
- package/bin/cli.js +222 -9
- package/package.json +1 -1
- package/src/advice.js +48 -41
- package/src/config.js +151 -0
- package/src/demo.js +251 -0
- package/src/formatters/statusline.js +36 -19
- package/src/formatters/table.js +9 -9
- package/src/history.js +123 -0
- package/src/installer.js +136 -0
- package/src/paths.js +41 -0
package/src/demo.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo scenarios for the statusline — used for screencasts/marketing GIFs
|
|
3
|
+
* embedded in the GitHub README and npm page.
|
|
4
|
+
*
|
|
5
|
+
* Activated via:
|
|
6
|
+
* claude-token-saver --statusline --demo healthy
|
|
7
|
+
* claude-token-saver --statusline --demo cycle # rotates every 3s
|
|
8
|
+
*
|
|
9
|
+
* Each scenario builds the same data shape the real pipeline produces, so
|
|
10
|
+
* it flows through formatReport() unchanged.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SCENARIOS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'healthy',
|
|
16
|
+
label: '✅ Healthy baseline',
|
|
17
|
+
data: {
|
|
18
|
+
hitRate: 0.983,
|
|
19
|
+
pct1h: 0.95,
|
|
20
|
+
savings: 2123,
|
|
21
|
+
elapsedSec: 30,
|
|
22
|
+
contextSize: '200k',
|
|
23
|
+
spikeChip: null,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'low-hit',
|
|
28
|
+
label: '⚠ Cache miss (low hit rate)',
|
|
29
|
+
data: {
|
|
30
|
+
hitRate: 0.55,
|
|
31
|
+
pct1h: 0.95,
|
|
32
|
+
savings: 240,
|
|
33
|
+
elapsedSec: 30,
|
|
34
|
+
contextSize: '200k',
|
|
35
|
+
spikeChip: '⚠ Cache miss',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'ttl-warning',
|
|
40
|
+
label: '⚠ TTL nearly out (yellow)',
|
|
41
|
+
data: {
|
|
42
|
+
hitRate: 0.983,
|
|
43
|
+
pct1h: 0.95,
|
|
44
|
+
savings: 2123,
|
|
45
|
+
elapsedSec: 3000, // ~10min remaining of 1h
|
|
46
|
+
contextSize: '200k',
|
|
47
|
+
spikeChip: null,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'ttl-expiring',
|
|
52
|
+
label: '⚠ TTL almost expired (red)',
|
|
53
|
+
data: {
|
|
54
|
+
hitRate: 0.983,
|
|
55
|
+
pct1h: 0.95,
|
|
56
|
+
savings: 2123,
|
|
57
|
+
elapsedSec: 3360, // ~4min remaining of 1h
|
|
58
|
+
contextSize: '200k',
|
|
59
|
+
spikeChip: null,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'ttl-expired',
|
|
64
|
+
label: '⚠ TTL EXPIRED',
|
|
65
|
+
data: {
|
|
66
|
+
hitRate: 0.983,
|
|
67
|
+
pct1h: 0.95,
|
|
68
|
+
savings: 2123,
|
|
69
|
+
elapsedSec: 4000, // past 1h
|
|
70
|
+
contextSize: '200k',
|
|
71
|
+
spikeChip: null,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: '5m-bucket',
|
|
76
|
+
label: '⚠ 5m TTL dominant (Pro plan)',
|
|
77
|
+
data: {
|
|
78
|
+
hitRate: 0.92,
|
|
79
|
+
pct1h: 0.15,
|
|
80
|
+
pct5m: 0.85,
|
|
81
|
+
savings: 410,
|
|
82
|
+
elapsedSec: 60,
|
|
83
|
+
contextSize: '200k',
|
|
84
|
+
spikeChip: '⚠ 5m TTL',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'ctx-1m',
|
|
89
|
+
label: '⚠ 1M context auto-on',
|
|
90
|
+
data: {
|
|
91
|
+
hitRate: 0.78,
|
|
92
|
+
pct1h: 0.92,
|
|
93
|
+
savings: 1340,
|
|
94
|
+
elapsedSec: 30,
|
|
95
|
+
contextSize: '1M',
|
|
96
|
+
spikeChip: '⚠ 1M ON',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'spike-input',
|
|
101
|
+
label: '⚠ Input spike',
|
|
102
|
+
data: {
|
|
103
|
+
hitRate: 0.91,
|
|
104
|
+
pct1h: 0.95,
|
|
105
|
+
savings: 1820,
|
|
106
|
+
elapsedSec: 30,
|
|
107
|
+
contextSize: '200k',
|
|
108
|
+
spikeChip: '⚠ Input spike',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'spike-rebuild',
|
|
113
|
+
label: '⚠ Cache rebuild churn',
|
|
114
|
+
data: {
|
|
115
|
+
hitRate: 0.62,
|
|
116
|
+
pct1h: 0.85,
|
|
117
|
+
savings: 220,
|
|
118
|
+
elapsedSec: 30,
|
|
119
|
+
contextSize: '200k',
|
|
120
|
+
spikeChip: '⚠ Rebuild churn',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'spike-output',
|
|
125
|
+
label: '⚠ Output ratio high',
|
|
126
|
+
data: {
|
|
127
|
+
hitRate: 0.94,
|
|
128
|
+
pct1h: 0.95,
|
|
129
|
+
savings: 1450,
|
|
130
|
+
elapsedSec: 30,
|
|
131
|
+
contextSize: '200k',
|
|
132
|
+
spikeChip: '⚠ Output heavy',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'spike-calls',
|
|
137
|
+
label: '⚠ Request count surge',
|
|
138
|
+
data: {
|
|
139
|
+
hitRate: 0.93,
|
|
140
|
+
pct1h: 0.92,
|
|
141
|
+
savings: 980,
|
|
142
|
+
elapsedSec: 30,
|
|
143
|
+
contextSize: '200k',
|
|
144
|
+
spikeChip: '⚠ Call surge',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
export function listScenarios() {
|
|
150
|
+
return SCENARIOS.map((s) => ({ name: s.name, label: s.label }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Synthetic spike-report data for `--demo table` — exercises every issue
|
|
155
|
+
* code so the drill-down section renders all six advice blocks. Used for
|
|
156
|
+
* marketing recordings of the table view.
|
|
157
|
+
*/
|
|
158
|
+
export function buildTableDemoData(options = {}) {
|
|
159
|
+
const issueCodes = [
|
|
160
|
+
'LARGE_INPUT_PER_REQUEST',
|
|
161
|
+
'LOW_HIT_RATE',
|
|
162
|
+
'BUCKET_5M_DOMINANT',
|
|
163
|
+
'HIGH_OUTPUT_RATIO',
|
|
164
|
+
'HIGH_REQUEST_COUNT',
|
|
165
|
+
'FREQUENT_CACHE_REBUILD',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const spikes = issueCodes.map((code, i) => ({
|
|
169
|
+
metrics: {
|
|
170
|
+
sessionId: `demo${String(i).padStart(4, '0')}-aaaa-bbbb`,
|
|
171
|
+
projectDir: ['ai-pipeline', 'frontend', 'data-eng', 'infra', 'docs-site', 'scratch'][i],
|
|
172
|
+
totalInput: [3_200_000, 850_000, 1_100_000, 620_000, 2_400_000, 740_000][i],
|
|
173
|
+
requestCount: [42, 128, 91, 67, 310, 58][i],
|
|
174
|
+
maxContextPerRequest: [280_000, 175_000, 195_000, 90_000, 145_000, 130_000][i],
|
|
175
|
+
},
|
|
176
|
+
ratio: [3.4, 2.1, 2.6, 1.9, 4.8, 2.3][i],
|
|
177
|
+
issues: [{ code }],
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
summary: {
|
|
182
|
+
sessions: 12,
|
|
183
|
+
apiCalls: 1247,
|
|
184
|
+
hitRate: 0.812,
|
|
185
|
+
totalInput: 9_540_000_000,
|
|
186
|
+
},
|
|
187
|
+
trend: [
|
|
188
|
+
{ date: '2026-04-23', hitRate: 0.94, calls: 312, totalRead: 2.1e8, totalWrite: 8.2e6, pct5m: 0.08 },
|
|
189
|
+
{ date: '2026-04-24', hitRate: 0.78, calls: 488, totalRead: 1.4e8, totalWrite: 1.5e7, pct5m: 0.42 },
|
|
190
|
+
{ date: '2026-04-25', hitRate: 0.71, calls: 447, totalRead: 9.8e7, totalWrite: 2.1e7, pct5m: 0.61 },
|
|
191
|
+
],
|
|
192
|
+
ttl: {
|
|
193
|
+
ephemeral5m: 1.5e7,
|
|
194
|
+
ephemeral1h: 2.4e7,
|
|
195
|
+
total: 3.9e7,
|
|
196
|
+
pct5m: 0.38,
|
|
197
|
+
pct1h: 0.62,
|
|
198
|
+
},
|
|
199
|
+
anomalies: [],
|
|
200
|
+
cost: {
|
|
201
|
+
tier: 'claude-opus-new',
|
|
202
|
+
actual: 487.32,
|
|
203
|
+
noCacheCost: 2143.91,
|
|
204
|
+
savings: 1656.59,
|
|
205
|
+
savingsRate: 0.773,
|
|
206
|
+
scenario5mCost: 612.04,
|
|
207
|
+
extraCostIf5m: 124.72,
|
|
208
|
+
},
|
|
209
|
+
options: {
|
|
210
|
+
days: options.days ?? 7,
|
|
211
|
+
windowHours: options.windowHours ?? 168,
|
|
212
|
+
windowLabel: options.windowLabel ?? '7d',
|
|
213
|
+
version: options.version ?? '',
|
|
214
|
+
},
|
|
215
|
+
spikeReport: { spikes, baseline: { p95: 940_000 } },
|
|
216
|
+
contextWindow: { size: '1M', maxContext: 280_000 },
|
|
217
|
+
lastActivity: Date.now() - 60 * 1000,
|
|
218
|
+
spikeChip: '⚠ 1M ON',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
export function buildScenarioData(scenarioName, options) {
|
|
224
|
+
let scenario;
|
|
225
|
+
if (scenarioName === 'cycle') {
|
|
226
|
+
// Bucket Date.now() into N-second slots, rotate through scenarios.
|
|
227
|
+
const slot = Math.floor(Date.now() / (options.cycleSeconds * 1000)) % SCENARIOS.length;
|
|
228
|
+
scenario = SCENARIOS[slot];
|
|
229
|
+
} else {
|
|
230
|
+
scenario = SCENARIOS.find((s) => s.name === scenarioName);
|
|
231
|
+
if (!scenario) return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, spikeChip } = scenario.data;
|
|
235
|
+
return {
|
|
236
|
+
summary: { hitRate },
|
|
237
|
+
ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
|
|
238
|
+
cost: { savings },
|
|
239
|
+
options: {
|
|
240
|
+
days: options.days ?? 1,
|
|
241
|
+
windowHours: options.windowHours ?? 24,
|
|
242
|
+
windowLabel: options.windowLabel ?? '1d',
|
|
243
|
+
version: options.version ?? '',
|
|
244
|
+
},
|
|
245
|
+
lastActivity: Date.now() - elapsedSec * 1000,
|
|
246
|
+
contextWindow: { size: contextSize },
|
|
247
|
+
spikeChip,
|
|
248
|
+
_demoLabel: scenario.label,
|
|
249
|
+
_demoName: scenario.name,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -87,25 +87,31 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
87
87
|
: 'Cache hit';
|
|
88
88
|
const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
89
89
|
|
|
90
|
-
// text: "
|
|
91
|
-
// icon: "💰 $1.5K" | verbose: "💰
|
|
90
|
+
// text: "Cache saved $1.5K" | same in verbose
|
|
91
|
+
// icon: "💰 $1.5K" | verbose: "💰 Cache saved $1.5K"
|
|
92
92
|
const saveLabel = isIcon
|
|
93
|
-
? (verbose ? '💰
|
|
94
|
-
: '
|
|
93
|
+
? (verbose ? '💰 Cache saved' : '💰')
|
|
94
|
+
: 'Cache saved';
|
|
95
95
|
const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
96
96
|
|
|
97
|
+
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
|
98
|
+
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
99
|
+
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
97
100
|
const periodSeg = verbose
|
|
98
|
-
? `${c(GRAY)}last ${
|
|
99
|
-
: `${c(GRAY)}${
|
|
101
|
+
? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
|
|
102
|
+
: `${c(GRAY)}${periodLabel}${c(RESET)}`;
|
|
100
103
|
|
|
101
104
|
// TTL countdown — how much time is left on the last API call's cache entry.
|
|
102
105
|
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
103
106
|
// TTL window, and the next call (hit) within that window resets it. So the
|
|
104
107
|
// countdown visibly ticks down between prompts, and "resets" happens as a
|
|
105
108
|
// jump back toward the bucket max the moment you send another message.
|
|
106
|
-
//
|
|
109
|
+
// Compact modes drop the bucket label — it's read as part of the clock
|
|
110
|
+
// ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
|
|
111
|
+
// is plan-determined and rarely changes, so verbose mode is where it belongs.
|
|
112
|
+
// text compact: "Expires 59:58"
|
|
107
113
|
// text verbose: "1h bucket · expires in 59:58"
|
|
108
|
-
// icon compact: "⏳
|
|
114
|
+
// icon compact: "⏳ 59:58"
|
|
109
115
|
// icon verbose: "⏳ Expires 1h 59:58"
|
|
110
116
|
let ttlSeg;
|
|
111
117
|
if (timer && lastActivity) {
|
|
@@ -119,22 +125,28 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
119
125
|
pct > 0.10 ? YELLOW :
|
|
120
126
|
RED;
|
|
121
127
|
|
|
122
|
-
if (isIcon) {
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
if (isIcon && verbose) {
|
|
129
|
+
// Drop bucket here too — `⏳ Expires 1h 57:20` reads as "1h 57m 20s left"
|
|
130
|
+
// for the same reason the compact form did. The bucket lives in the
|
|
131
|
+
// text-verbose layout where the "bucket" word + `·` separator make it
|
|
132
|
+
// unambiguous.
|
|
133
|
+
ttlSeg = `${c(timerColor)}⏳ Cache expires ${text}${c(RESET)}`;
|
|
134
|
+
} else if (isIcon) {
|
|
135
|
+
ttlSeg = `${c(timerColor)}⏳ ${text}${c(RESET)}`;
|
|
125
136
|
} else if (verbose) {
|
|
126
|
-
ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
137
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
127
138
|
} else {
|
|
128
|
-
ttlSeg = `${c(
|
|
139
|
+
ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
|
|
129
140
|
}
|
|
130
141
|
} else {
|
|
142
|
+
// No-timer fallback: only the bucket is available, so we show just that.
|
|
131
143
|
if (isIcon) {
|
|
132
|
-
const prefix = verbose ? '⏳
|
|
144
|
+
const prefix = verbose ? '⏳ Cache bucket ' : '⏳ ';
|
|
133
145
|
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
134
146
|
} else if (verbose) {
|
|
135
|
-
ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)}`;
|
|
147
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
136
148
|
} else {
|
|
137
|
-
ttlSeg = `${c(bucketColor)}
|
|
149
|
+
ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
|
|
138
150
|
}
|
|
139
151
|
}
|
|
140
152
|
|
|
@@ -144,7 +156,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
144
156
|
if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
145
157
|
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
146
158
|
const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
|
|
147
|
-
if (isIcon) {
|
|
159
|
+
if (isIcon && verbose) {
|
|
160
|
+
ctxSeg = `${c(ctxColor)}📦 Context ${label}${c(RESET)}`;
|
|
161
|
+
} else if (isIcon) {
|
|
148
162
|
ctxSeg = `${c(ctxColor)}📦 ${label}${c(RESET)}`;
|
|
149
163
|
} else if (verbose) {
|
|
150
164
|
ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
|
|
@@ -156,9 +170,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
156
170
|
// Spike chip — one word only, keeps the statusline single-line.
|
|
157
171
|
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
158
172
|
|
|
159
|
-
|
|
160
|
-
|
|
173
|
+
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
174
|
+
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
175
|
+
const segs = [];
|
|
161
176
|
if (spikeSeg) segs.push(spikeSeg);
|
|
177
|
+
segs.push(hitSeg, ttlSeg, saveSeg);
|
|
178
|
+
if (ctxSeg) segs.push(ctxSeg);
|
|
162
179
|
segs.push(periodSeg);
|
|
163
180
|
return segs.join(' · ');
|
|
164
181
|
}
|
package/src/formatters/table.js
CHANGED
|
@@ -64,11 +64,11 @@ function formatContextSize(n) {
|
|
|
64
64
|
|
|
65
65
|
function renderSpikeSection(spikes, contextWindow) {
|
|
66
66
|
const lines = [];
|
|
67
|
-
lines.push(' ⚠
|
|
67
|
+
lines.push(' ⚠ Token spike detected');
|
|
68
68
|
lines.push(` ${'─'.repeat(50)}`);
|
|
69
69
|
if (contextWindow && contextWindow.size === '1M') {
|
|
70
70
|
lines.push(
|
|
71
|
-
`
|
|
71
|
+
` Context mode: 1M (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`,
|
|
72
72
|
);
|
|
73
73
|
lines.push('');
|
|
74
74
|
}
|
|
@@ -77,11 +77,11 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
77
77
|
const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
|
|
78
78
|
lines.push(
|
|
79
79
|
` • ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] ` +
|
|
80
|
-
|
|
80
|
+
`total input ${formatContextSize(m.totalInput)} (${ratioLabel}, ${m.requestCount} requests)`,
|
|
81
81
|
);
|
|
82
82
|
if (m.maxContextPerRequest > 0) {
|
|
83
83
|
lines.push(
|
|
84
|
-
`
|
|
84
|
+
` max single-request context: ${formatContextSize(m.maxContextPerRequest)} tokens`,
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
for (const issue of spike.issues) {
|
|
@@ -104,7 +104,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
if (uniqueIssues.length > 0) {
|
|
107
|
-
lines.push('
|
|
107
|
+
lines.push(' Recommended actions');
|
|
108
108
|
lines.push(` ${'─'.repeat(50)}`);
|
|
109
109
|
for (const issue of uniqueIssues) {
|
|
110
110
|
const info = ISSUE_MESSAGES[issue.code];
|
|
@@ -128,7 +128,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
128
128
|
|
|
129
129
|
// Header
|
|
130
130
|
lines.push('');
|
|
131
|
-
lines.push(` Claude
|
|
131
|
+
lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
|
|
132
132
|
lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
|
|
133
133
|
lines.push(` ${'═'.repeat(50)}`);
|
|
134
134
|
lines.push('');
|
|
@@ -142,10 +142,10 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
142
142
|
if (contextWindow && contextWindow.size !== 'unknown') {
|
|
143
143
|
const note =
|
|
144
144
|
contextWindow.size === '1M'
|
|
145
|
-
? '⚠ 1M
|
|
146
|
-
: '✓ 200k
|
|
145
|
+
? '⚠ 1M context active (Opus 4.7+ Max default). Disable with CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
|
|
146
|
+
: '✓ 200k context (standard)';
|
|
147
147
|
lines.push(` Context window: ${contextWindow.size} ${note}`);
|
|
148
|
-
lines.push(` (
|
|
148
|
+
lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
|
|
149
149
|
lines.push('');
|
|
150
150
|
}
|
|
151
151
|
|
package/src/history.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warning history — appends an entry whenever the active chip transitions
|
|
3
|
+
* (none → warning, warning A → warning B, warning → none). One markdown file
|
|
4
|
+
* per calendar day so users can pinpoint "when did this start" easily.
|
|
5
|
+
*
|
|
6
|
+
* Storage path is platform-aware (see paths.userDataDir):
|
|
7
|
+
* Windows: %APPDATA%\claude-token-saver\history\YYYY-MM-DD.md
|
|
8
|
+
* macOS: ~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md
|
|
9
|
+
* Linux: ~/.config/claude-token-saver/history/YYYY-MM-DD.md
|
|
10
|
+
*
|
|
11
|
+
* State (last-seen chip, prevents duplicate appends every 1s refresh):
|
|
12
|
+
* <userDataDir>/last-chip.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { userDataDir } from './paths.js';
|
|
18
|
+
|
|
19
|
+
const BASE_DIR = userDataDir();
|
|
20
|
+
const HISTORY_DIR = join(BASE_DIR, 'history');
|
|
21
|
+
const STATE_PATH = join(BASE_DIR, 'last-chip.json');
|
|
22
|
+
|
|
23
|
+
export function historyDir() {
|
|
24
|
+
return HISTORY_DIR;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ensureDir(p) {
|
|
28
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pad(n) {
|
|
32
|
+
return String(n).padStart(2, '0');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ymd(d = new Date()) {
|
|
36
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hms(d = new Date()) {
|
|
40
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadState() {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return { chip: null, ts: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function saveState(state) {
|
|
52
|
+
ensureDir(BASE_DIR);
|
|
53
|
+
writeFileSync(STATE_PATH, JSON.stringify(state) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function appendDayLine(line, date = new Date()) {
|
|
57
|
+
ensureDir(HISTORY_DIR);
|
|
58
|
+
const path = join(HISTORY_DIR, `${ymd(date)}.md`);
|
|
59
|
+
if (!existsSync(path)) {
|
|
60
|
+
writeFileSync(path, `# Token Monitor — ${ymd(date)}\n\n## Events\n${line}\n`);
|
|
61
|
+
} else {
|
|
62
|
+
const existing = readFileSync(path, 'utf8');
|
|
63
|
+
writeFileSync(path, existing.endsWith('\n') ? existing + line + '\n' : existing + '\n' + line + '\n');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record a chip transition. Called from the statusline render path.
|
|
69
|
+
* Returns `true` if a transition was logged, `false` if duplicate (same chip
|
|
70
|
+
* as last call).
|
|
71
|
+
*/
|
|
72
|
+
export function recordChip(chip, contextHints = {}) {
|
|
73
|
+
const state = loadState();
|
|
74
|
+
const now = new Date();
|
|
75
|
+
const current = chip || null;
|
|
76
|
+
const last = state.chip || null;
|
|
77
|
+
|
|
78
|
+
if (current === last) return false;
|
|
79
|
+
|
|
80
|
+
let line;
|
|
81
|
+
if (current && !last) {
|
|
82
|
+
line = `- ${hms(now)} ${current}` + (contextHints.detail ? ` — ${contextHints.detail}` : '');
|
|
83
|
+
} else if (current && last) {
|
|
84
|
+
line = `- ${hms(now)} ${last} → ${current}` + (contextHints.detail ? ` — ${contextHints.detail}` : '');
|
|
85
|
+
} else {
|
|
86
|
+
// current === null, last was something — warning resolved
|
|
87
|
+
line = `- ${hms(now)} ✓ resolved (was ${last})`;
|
|
88
|
+
}
|
|
89
|
+
appendDayLine(line, now);
|
|
90
|
+
saveState({ chip: current, ts: now.toISOString() });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read history files for the most recent N days (oldest first).
|
|
96
|
+
* Returns array of { date, content } — empty content for days with no file.
|
|
97
|
+
*/
|
|
98
|
+
export function readRecent(days = 7) {
|
|
99
|
+
ensureDir(HISTORY_DIR);
|
|
100
|
+
const out = [];
|
|
101
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
102
|
+
const d = new Date();
|
|
103
|
+
d.setDate(d.getDate() - i);
|
|
104
|
+
const date = ymd(d);
|
|
105
|
+
const path = join(HISTORY_DIR, `${date}.md`);
|
|
106
|
+
if (existsSync(path)) {
|
|
107
|
+
out.push({ date, content: readFileSync(path, 'utf8') });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* List all available history file dates (sorted newest first).
|
|
115
|
+
*/
|
|
116
|
+
export function listDates() {
|
|
117
|
+
ensureDir(HISTORY_DIR);
|
|
118
|
+
return readdirSync(HISTORY_DIR)
|
|
119
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
|
120
|
+
.map((f) => f.replace(/\.md$/, ''))
|
|
121
|
+
.sort()
|
|
122
|
+
.reverse();
|
|
123
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs the Claude Code integration assets:
|
|
3
|
+
* - Skill: ~/.claude/skills/claude-token-saver/SKILL.md
|
|
4
|
+
* - Slash: ~/.claude/commands/token-monitor.md
|
|
5
|
+
*
|
|
6
|
+
* All paths are resolved with node:path so Windows backslashes and POSIX
|
|
7
|
+
* forward-slashes are both handled. Directories are created with
|
|
8
|
+
* `mkdirSync(..., { recursive: true })` which is a no-op if they already
|
|
9
|
+
* exist on every platform.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { claudeUserDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
const SKILL_BODY = `---
|
|
17
|
+
name: claude-token-saver
|
|
18
|
+
description: Use when the user mentions Claude Code token usage, prompt cache hit rate, TTL/expiry, the 1M context window, cache misses, output spikes, or anything in the statusline produced by claude-token-saver (chips like "⚠ 1M ON", "⚠ Input spike", "⚠ Cache miss", "⚠ 5m TTL", "⚠ Rebuild churn", "⚠ Output heavy", "⚠ Call surge", "⏳ Cache expires", "💰 Cache saved", "🧠 Cache hit"). Also use when they ask to view token-usage history or want to understand a warning they just saw.
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# claude-token-saver — Claude Code Token Monitor
|
|
22
|
+
|
|
23
|
+
This skill helps users interpret and act on the \`claude-token-saver\` statusline
|
|
24
|
+
in Claude Code. The statusline updates every ~1s and shows cache health, TTL
|
|
25
|
+
countdown, savings, and (when relevant) a leading warning chip.
|
|
26
|
+
|
|
27
|
+
## When this skill should activate
|
|
28
|
+
|
|
29
|
+
- The user references any chip wording: \`⚠ 1M ON\`, \`⚠ Input spike\`,
|
|
30
|
+
\`⚠ Cache miss\`, \`⚠ 5m TTL\`, \`⚠ Rebuild churn\`, \`⚠ Output heavy\`,
|
|
31
|
+
\`⚠ Call surge\`.
|
|
32
|
+
- The user asks "why is my cache hit rate low", "what does this warning mean",
|
|
33
|
+
"when did this start happening", or similar.
|
|
34
|
+
- The user wants to see the token-usage history file or asks for a summary
|
|
35
|
+
of recent warnings.
|
|
36
|
+
|
|
37
|
+
## What to do
|
|
38
|
+
|
|
39
|
+
1. **Identify the chip.** If the user pasted a statusline, pull out the leading
|
|
40
|
+
\`⚠ ...\` chip. That maps to a specific issue category.
|
|
41
|
+
2. **Show recent history.** Run \`claude-token-saver history\` (default last 7
|
|
42
|
+
days) to see the chronology of warning transitions. Each entry is timestamped
|
|
43
|
+
and includes a short detail string.
|
|
44
|
+
3. **Drill down on the live state.** Run \`claude-token-saver --days 1\` (or
|
|
45
|
+
another window) to render the full table view, which lists per-session
|
|
46
|
+
spikes and recommended actions.
|
|
47
|
+
4. **Explain the warning** in plain language. Use the chip → cause table:
|
|
48
|
+
|
|
49
|
+
| Chip | Likely cause |
|
|
50
|
+
| ------------------ | ----------------------------------------------------- |
|
|
51
|
+
| \`⚠ 1M ON\` | Auto-promoted to 1M context (Opus 4.7+ Max default). |
|
|
52
|
+
| \`⚠ Input spike\` | One request consumed >250k or >3× the recent p95. |
|
|
53
|
+
| \`⚠ Cache miss\` | Cache hit rate dropped below ~70%. |
|
|
54
|
+
| \`⚠ 5m TTL\` | Most cache writes are 5-min ephemeral (Pro plan default). |
|
|
55
|
+
| \`⚠ Rebuild churn\` | Cache being re-written rapidly — prefix is unstable. |
|
|
56
|
+
| \`⚠ Output heavy\` | Output ratio dominates input — inspect long generations. |
|
|
57
|
+
| \`⚠ Call surge\` | Request count is well above baseline. |
|
|
58
|
+
|
|
59
|
+
5. **Suggest the next action.** For 1M ON, mention
|
|
60
|
+
\`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For 5m TTL, point at the Max plan's
|
|
61
|
+
1h bucket. For input spike, suggest splitting the conversation or
|
|
62
|
+
compacting context.
|
|
63
|
+
|
|
64
|
+
## Useful commands
|
|
65
|
+
|
|
66
|
+
- \`claude-token-saver\` — full table report (default last 1 day).
|
|
67
|
+
- \`claude-token-saver --days 7\` — wider window.
|
|
68
|
+
- \`claude-token-saver history\` — recent warning transitions per day.
|
|
69
|
+
- \`claude-token-saver history --days 30\` — longer history.
|
|
70
|
+
- \`claude-token-saver mode\` — show statusline preferences.
|
|
71
|
+
- \`claude-token-saver mode icon verbose 1d\` — change preferences.
|
|
72
|
+
|
|
73
|
+
## Storage layout (for reference)
|
|
74
|
+
|
|
75
|
+
History files live under the OS-appropriate user-data dir:
|
|
76
|
+
- Windows: \`%APPDATA%\\claude-token-saver\\history\\YYYY-MM-DD.md\`
|
|
77
|
+
- macOS: \`~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md\`
|
|
78
|
+
- Linux: \`~/.config/claude-token-saver/history/YYYY-MM-DD.md\`
|
|
79
|
+
|
|
80
|
+
Each day's file is plain Markdown — safe to open in any editor.
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const COMMAND_BODY = `---
|
|
84
|
+
description: Show recent claude-token-saver warning history and a fresh report.
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
You are responding to the \`/token-monitor\` slash command. The user wants a
|
|
88
|
+
quick read of their Claude Code token usage and any active warnings.
|
|
89
|
+
|
|
90
|
+
Steps:
|
|
91
|
+
|
|
92
|
+
1. Run \`claude-token-saver history --days 7\` and capture the output. This
|
|
93
|
+
prints recent warning transitions (timestamps + chip + short detail).
|
|
94
|
+
2. Run \`claude-token-saver --days 1\` and capture the output. This prints the
|
|
95
|
+
full table view: TTL breakdown, cost impact, daily trend, and any active
|
|
96
|
+
spikes with recommended actions.
|
|
97
|
+
3. Summarize for the user:
|
|
98
|
+
- **Active warnings** — list the most recent unresolved chip(s) with the
|
|
99
|
+
time they appeared.
|
|
100
|
+
- **Today's pattern** — when warnings cluster in time, mention it.
|
|
101
|
+
- **Recommended action** — pick the highest-leverage suggestion from the
|
|
102
|
+
table report's "Recommended actions" section.
|
|
103
|
+
4. If the history is empty, say so plainly — no warnings means the cache has
|
|
104
|
+
been healthy in the configured window.
|
|
105
|
+
|
|
106
|
+
Keep the summary to ~10 lines. The user can re-run the underlying commands
|
|
107
|
+
themselves for the full output.
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
function writeIfNeeded(file, body, force) {
|
|
111
|
+
const existed = existsSync(file);
|
|
112
|
+
if (existed && !force) return { path: file, action: 'exists' };
|
|
113
|
+
writeFileSync(file, body);
|
|
114
|
+
return { path: file, action: existed ? 'updated' : 'created' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function installSkill({ force = false } = {}) {
|
|
118
|
+
const dir = join(claudeUserDir(), 'skills', 'claude-token-saver');
|
|
119
|
+
const file = join(dir, 'SKILL.md');
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
return writeIfNeeded(file, SKILL_BODY, force);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function installCommand({ force = false } = {}) {
|
|
125
|
+
const dir = join(claudeUserDir(), 'commands');
|
|
126
|
+
const file = join(dir, 'token-monitor.md');
|
|
127
|
+
mkdirSync(dir, { recursive: true });
|
|
128
|
+
return writeIfNeeded(file, COMMAND_BODY, force);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function installAll({ force = false } = {}) {
|
|
132
|
+
return {
|
|
133
|
+
skill: installSkill({ force }),
|
|
134
|
+
command: installCommand({ force }),
|
|
135
|
+
};
|
|
136
|
+
}
|