claude-token-saver 2.2.0 → 2.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/README.md +18 -0
- package/bin/cli.js +194 -28
- package/package.json +1 -1
- package/src/advice.js +120 -0
- package/src/caps-cache.js +46 -13
- package/src/demo.js +73 -1
- package/src/format-time.js +44 -0
- package/src/formatters/statusline.js +176 -38
- package/src/formatters/table.js +15 -21
- package/src/handoff.js +19 -20
- package/src/history.js +96 -31
- package/src/installer.js +39 -28
- package/src/window-labels.js +61 -0
package/src/history.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { userDataDir } from './paths.js';
|
|
23
|
+
import { formatResetIn, formatResetClock } from './format-time.js';
|
|
24
|
+
import { labelForKey } from './window-labels.js';
|
|
25
|
+
import { ISSUE_TIPS, CHIP_TO_CODES, CAP_TIPS } from './advice.js';
|
|
23
26
|
|
|
24
27
|
const BASE_DIR = userDataDir();
|
|
25
28
|
const HISTORY_DIR = join(BASE_DIR, 'history');
|
|
@@ -111,10 +114,56 @@ function detailKo(detail) {
|
|
|
111
114
|
return detail;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the diagnostic codes that apply to a transition. We try, in order:
|
|
119
|
+
* 1. `detail` of shape "session ID: CODE_A, CODE_B" (chip transitions with
|
|
120
|
+
* explicit per-session codes — the richest source).
|
|
121
|
+
* 2. `chip` text mapped via CHIP_TO_CODES (covers 1M ON and chips that fire
|
|
122
|
+
* without a per-session detail).
|
|
123
|
+
* Returns an array of unique codes, possibly empty.
|
|
124
|
+
*/
|
|
125
|
+
function codesForEvent(chip, detail) {
|
|
126
|
+
const out = [];
|
|
127
|
+
if (detail) {
|
|
128
|
+
const m = detail.match(/^session [^:]+:\s*(.+)$/);
|
|
129
|
+
if (m) {
|
|
130
|
+
for (const c of m[1].split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
131
|
+
if (!out.includes(c)) out.push(c);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (chip && CHIP_TO_CODES[chip]) {
|
|
136
|
+
for (const c of CHIP_TO_CODES[chip]) if (!out.includes(c)) out.push(c);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the "💡 ..." tip block for a given chip+detail pair. Returns
|
|
143
|
+
* { en: string, ko: string } where each may be empty if no tips apply
|
|
144
|
+
* (resolution events, unknown chips, etc.).
|
|
145
|
+
*/
|
|
146
|
+
function tipsForEvent(chip, detail) {
|
|
147
|
+
const codes = codesForEvent(chip, detail);
|
|
148
|
+
const enLines = [];
|
|
149
|
+
const koLines = [];
|
|
150
|
+
for (const code of codes) {
|
|
151
|
+
const tip = ISSUE_TIPS[code];
|
|
152
|
+
if (!tip) continue;
|
|
153
|
+
enLines.push(` 💡 ${tip.en}`);
|
|
154
|
+
koLines.push(` 💡 ${tip.ko}`);
|
|
155
|
+
}
|
|
156
|
+
return { en: enLines.join('\n'), ko: koLines.join('\n') };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function appendDayLine(en, ko, date = new Date(), tips = null) {
|
|
115
160
|
ensureDir(HISTORY_DIR);
|
|
116
161
|
const path = join(HISTORY_DIR, `${ymd(date)}.md`);
|
|
117
|
-
|
|
162
|
+
// Bilingual event line. Tip lines (when present) follow on their own lines so
|
|
163
|
+
// the file reads as: event-en / └ event-ko / 💡 tip-en / 💡 tip-ko.
|
|
164
|
+
let block = ko && ko !== en ? `${en}\n └ ${ko}\n` : `${en}\n`;
|
|
165
|
+
if (tips && tips.en) block += `${tips.en}\n`;
|
|
166
|
+
if (tips && tips.ko) block += `${tips.ko}\n`;
|
|
118
167
|
if (!existsSync(path)) {
|
|
119
168
|
const header = `# Token Monitor / 토큰 모니터 — ${ymd(date)}\n\n## Events / 이벤트\n`;
|
|
120
169
|
writeFileSync(path, header + block);
|
|
@@ -143,18 +192,23 @@ export function recordChip(chip, contextHints = {}) {
|
|
|
143
192
|
const detailKr = detail ? ` — ${detailKo(detail)}` : '';
|
|
144
193
|
|
|
145
194
|
let en, ko;
|
|
195
|
+
// Tips only on warning entry/transition (not resolution) — based on the
|
|
196
|
+
// *new* chip so users see how to handle what's currently active.
|
|
197
|
+
let tips = null;
|
|
146
198
|
if (current && !last) {
|
|
147
199
|
en = `- ${hms(now)} ${current}${detailEn}`;
|
|
148
200
|
ko = `${chipKo(current)}${detailKr}`;
|
|
201
|
+
tips = tipsForEvent(current, detail);
|
|
149
202
|
} else if (current && last) {
|
|
150
203
|
en = `- ${hms(now)} ${last} → ${current}${detailEn}`;
|
|
151
204
|
ko = `${chipKo(last)} → ${chipKo(current)}${detailKr}`;
|
|
205
|
+
tips = tipsForEvent(current, detail);
|
|
152
206
|
} else {
|
|
153
207
|
// current === null, last was something — warning resolved
|
|
154
208
|
en = `- ${hms(now)} ✓ resolved (was ${last})`;
|
|
155
209
|
ko = `✓ 해소됨 (이전: ${chipKo(last)})`;
|
|
156
210
|
}
|
|
157
|
-
appendDayLine(en, ko, now);
|
|
211
|
+
appendDayLine(en, ko, now, tips);
|
|
158
212
|
saveState({ chip: current, ts: now.toISOString() });
|
|
159
213
|
return true;
|
|
160
214
|
}
|
|
@@ -178,53 +232,64 @@ export function readRecent(days = 7) {
|
|
|
178
232
|
return out;
|
|
179
233
|
}
|
|
180
234
|
|
|
181
|
-
/**
|
|
182
|
-
* Format "resets in Hh Mm" / "Mm" given a Unix-epoch resets_at value.
|
|
183
|
-
* Returns null when the input isn't a finite number.
|
|
184
|
-
*/
|
|
185
|
-
function formatResetIn(resetsAt, now = new Date()) {
|
|
186
|
-
if (!Number.isFinite(resetsAt)) return null;
|
|
187
|
-
const remainingSec = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
|
|
188
|
-
if (remainingSec <= 0) return '0m';
|
|
189
|
-
const h = Math.floor(remainingSec / 3600);
|
|
190
|
-
const m = Math.floor((remainingSec % 3600) / 60);
|
|
191
|
-
if (h > 0) return `${h}h ${m}m`;
|
|
192
|
-
return `${m}m`;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
235
|
/**
|
|
196
236
|
* Record entering or exiting the cap-warn (>=90%) zone for a rate-limit
|
|
197
|
-
* window. Each window (`five_hour`, `seven_day`)
|
|
198
|
-
* the daily file gets two transitions max per
|
|
237
|
+
* window. Each window (keyed by its stdin name — e.g. `five_hour`, `seven_day`)
|
|
238
|
+
* has its own dedup slot, so the daily file gets two transitions max per
|
|
239
|
+
* window per warning episode.
|
|
199
240
|
*
|
|
200
|
-
* @param {
|
|
201
|
-
* @param {{ usedPct: number, resetsAt: number|null } | null} info
|
|
241
|
+
* @param {{ key: string, usedPct: number, resetsAt: number|null } | null} window
|
|
202
242
|
* @returns {boolean} true when a line was appended
|
|
203
243
|
*/
|
|
204
|
-
export function recordCapTransition(
|
|
244
|
+
export function recordCapTransition(window) {
|
|
245
|
+
if (!window || typeof window.key !== 'string') return false;
|
|
205
246
|
const state = loadState();
|
|
206
|
-
const slotKey = `cap_${
|
|
247
|
+
const slotKey = `cap_${window.key}`;
|
|
207
248
|
const wasWarn = !!state[slotKey];
|
|
208
|
-
const isWarn =
|
|
249
|
+
const isWarn = Number.isFinite(window.usedPct) && window.usedPct >= 90;
|
|
209
250
|
if (wasWarn === isWarn) return false;
|
|
210
251
|
|
|
211
252
|
const now = new Date();
|
|
212
|
-
const
|
|
213
|
-
const
|
|
253
|
+
const labels = labelForKey(window.key);
|
|
254
|
+
const labelEn = labels.short;
|
|
255
|
+
// Korean labels map only the well-known windows; everything else falls back
|
|
256
|
+
// to the English short label (still readable for the bilingual line).
|
|
257
|
+
const KO_OVERRIDES = {
|
|
258
|
+
five_hour: '5시간 윈도',
|
|
259
|
+
seven_day: '7일 윈도',
|
|
260
|
+
seven_day_sonnet: '7일 윈도 (Sonnet)',
|
|
261
|
+
seven_day_opus: '7일 윈도 (Opus)',
|
|
262
|
+
};
|
|
263
|
+
const labelKo = KO_OVERRIDES[window.key] || labels.short;
|
|
214
264
|
let en;
|
|
215
265
|
let ko;
|
|
216
266
|
if (isWarn) {
|
|
217
|
-
const pct = Math.round(
|
|
218
|
-
const reset = formatResetIn(
|
|
219
|
-
const
|
|
220
|
-
const
|
|
267
|
+
const pct = Math.round(window.usedPct);
|
|
268
|
+
const reset = formatResetIn(window.resetsAt, now);
|
|
269
|
+
const clock = formatResetClock(window.resetsAt, now);
|
|
270
|
+
const tail = reset && clock
|
|
271
|
+
? ` (resets in ${reset}, at ${clock})`
|
|
272
|
+
: reset
|
|
273
|
+
? ` (resets in ${reset})`
|
|
274
|
+
: clock
|
|
275
|
+
? ` (resets at ${clock})`
|
|
276
|
+
: '';
|
|
277
|
+
const tailKo = reset && clock
|
|
278
|
+
? ` (${clock}에 리셋, 남은 ${reset})`
|
|
279
|
+
: reset
|
|
280
|
+
? ` (리셋까지 ${reset})`
|
|
281
|
+
: clock
|
|
282
|
+
? ` (${clock}에 리셋)`
|
|
283
|
+
: '';
|
|
221
284
|
en = `- ${hms(now)} 🚨 ${labelEn} ${pct}% cap warning${tail}`;
|
|
222
285
|
ko = `🚨 ${labelKo} ${pct}% 캡 경고${tailKo}`;
|
|
223
286
|
} else {
|
|
224
287
|
en = `- ${hms(now)} ✓ ${labelEn} cap warning resolved`;
|
|
225
288
|
ko = `✓ ${labelKo} 캡 경고 해소`;
|
|
226
289
|
}
|
|
227
|
-
|
|
290
|
+
// Cap-warn entry → handoff tip; resolution → no tip (just the ✓ line).
|
|
291
|
+
const capTips = isWarn ? { en: ` 💡 ${CAP_TIPS.en}`, ko: ` 💡 ${CAP_TIPS.ko}` } : null;
|
|
292
|
+
appendDayLine(en, ko, now, capTips);
|
|
228
293
|
state[slotKey] = isWarn;
|
|
229
294
|
saveState(state);
|
|
230
295
|
return true;
|
package/src/installer.js
CHANGED
|
@@ -39,15 +39,20 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
39
39
|
|
|
40
40
|
## What to do
|
|
41
41
|
|
|
42
|
-
1. **
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
1. **Lead with the most recent warning + how to handle it.** Run
|
|
43
|
+
\`claude-token-saver last\` first. It returns the most recent warning event
|
|
44
|
+
(chip + detail + timestamp) plus the full advice block for it. Surface that
|
|
45
|
+
to the user before anything else — this is what they came for.
|
|
46
|
+
2. **Identify the chip.** If the user pasted a statusline (instead of relying
|
|
47
|
+
on \`last\`), pull out the leading \`⚠ ...\` chip. That maps to a specific
|
|
48
|
+
issue category.
|
|
49
|
+
3. **Show recent history.** Run \`claude-token-saver history\` (default last 7
|
|
50
|
+
days) to see the chronology of warning transitions. Each entry is timestamped,
|
|
51
|
+
bilingual (English line + 한국어), and includes a \`💡\` action tip inline.
|
|
52
|
+
4. **Drill down on the live state.** Run \`claude-token-saver --days 1\` (or
|
|
48
53
|
another window) to render the full table view, which lists per-session
|
|
49
54
|
spikes and recommended actions.
|
|
50
|
-
|
|
55
|
+
5. **Explain the warning** in plain language. Use the chip → cause table:
|
|
51
56
|
|
|
52
57
|
| Chip | Likely cause |
|
|
53
58
|
| ------------------ | ----------------------------------------------------- |
|
|
@@ -61,7 +66,7 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
61
66
|
| \`⚠ Output heavy\` | Output ratio dominates input — inspect long generations. |
|
|
62
67
|
| \`⚠ Call surge\` | Request count is well above baseline. |
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
6. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
|
|
65
70
|
\`claude-token-saver handoff\` to back up the current work to a
|
|
66
71
|
\`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
|
|
67
72
|
session. For 1M ON, mention \`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For
|
|
@@ -70,9 +75,12 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
70
75
|
|
|
71
76
|
## Useful commands
|
|
72
77
|
|
|
78
|
+
- \`claude-token-saver last\` — most recent warning + full advice (start here).
|
|
79
|
+
- \`claude-token-saver last --days 7\` — widen the lookback window.
|
|
73
80
|
- \`claude-token-saver\` — full table report (default last 1 day).
|
|
74
81
|
- \`claude-token-saver --days 7\` — wider window.
|
|
75
|
-
- \`claude-token-saver history\` — recent warning transitions per day
|
|
82
|
+
- \`claude-token-saver history\` — recent warning transitions per day, with
|
|
83
|
+
inline \`💡\` action tips.
|
|
76
84
|
- \`claude-token-saver history --days 30\` — longer history.
|
|
77
85
|
- \`claude-token-saver handoff\` — write a HANDOFF-*.md template in cwd
|
|
78
86
|
capturing git status + cap snapshot, so a fresh session can resume cleanly.
|
|
@@ -94,28 +102,31 @@ description: Show recent claude-token-saver warning history and a fresh report.
|
|
|
94
102
|
---
|
|
95
103
|
|
|
96
104
|
You are responding to the \`/token-monitor\` slash command. The user wants a
|
|
97
|
-
quick read of their Claude Code token usage and any active warnings
|
|
105
|
+
quick read of their Claude Code token usage and any active warnings — most
|
|
106
|
+
importantly: **what just happened, and how do I handle it?**
|
|
98
107
|
|
|
99
108
|
Steps:
|
|
100
109
|
|
|
101
|
-
1. Run \`claude-token-saver
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
110
|
+
1. **Lead with the most recent warning.** Run \`claude-token-saver last\`
|
|
111
|
+
first and surface its output verbatim (or lightly summarized) at the top
|
|
112
|
+
of your reply. This returns the latest warning event (chip + detail +
|
|
113
|
+
timestamp) followed by the full advice block. If \`last\` says no recent
|
|
114
|
+
warnings, mention that and skip ahead — you can stop here unless the user
|
|
115
|
+
asked for more.
|
|
116
|
+
2. Run \`claude-token-saver history --days 7\` and capture the output. Use it
|
|
117
|
+
only to add context — e.g. "this is the 3rd cache miss today" — not to
|
|
118
|
+
re-print the whole file. Each entry is bilingual and includes a \`💡\`
|
|
119
|
+
action tip inline.
|
|
120
|
+
3. Run \`claude-token-saver --days 1\` and capture the output for any extra
|
|
121
|
+
color you want to add: TTL breakdown, cost impact, daily trend, or active
|
|
122
|
+
spikes. Skip if step 1 already covered what the user needs.
|
|
123
|
+
4. Summarize for the user:
|
|
124
|
+
- **What just fired** — the chip + the time + a sentence on what caused it
|
|
125
|
+
(from \`last\`).
|
|
126
|
+
- **What to do** — the action tip from \`last\`. For cap-warn (\`🚨 5H/7D NN%\`),
|
|
127
|
+
surface \`claude-token-saver handoff\` prominently so they can back up
|
|
128
|
+
state before the cap blocks them.
|
|
129
|
+
- **Today's pattern** (optional) — when warnings cluster in time, mention it.
|
|
119
130
|
|
|
120
131
|
Keep the summary to ~10 lines. The user can re-run the underlying commands
|
|
121
132
|
themselves for the full output.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin
|
|
3
|
+
* payload. Known keys (`five_hour`, `seven_day`) get curated short/long labels
|
|
4
|
+
* and dedicated icons. Unknown keys are passed through with a derived label so
|
|
5
|
+
* any future window Anthropic adds (e.g. `seven_day_sonnet`) renders without
|
|
6
|
+
* a code change.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// `short` is used by the cap-warn chip and history records (stable shape so
|
|
10
|
+
// parsers/dedup keep working). `usageLabel` is the friendlier word that the
|
|
11
|
+
// always-on usage segment renders next to the icon — empty string means the
|
|
12
|
+
// icon alone carries the meaning (5H 'session/now' is the implicit default).
|
|
13
|
+
const KNOWN = {
|
|
14
|
+
// ✦ reads as "AI token unit" (Anthropic/OpenAI/Gemini sparkle motif), more
|
|
15
|
+
// on-theme than the 🪙 coin which suggested in-app currency. Same width as
|
|
16
|
+
// 📅 in monospace terminals so the chip alignment stays stable.
|
|
17
|
+
// 'current' mirrors how `weekly` reads next to 7D — names the window in
|
|
18
|
+
// plain English so a glance at `✦ current ████▒░ 72%` tells the eye what's
|
|
19
|
+
// being measured without parsing the icon's meaning.
|
|
20
|
+
five_hour: { short: '5H', long: 'Current session', icon: '✦', usageLabel: 'current' },
|
|
21
|
+
seven_day: { short: '7D', long: 'Current week', icon: '📅', usageLabel: 'weekly' },
|
|
22
|
+
// Speculative — `/usage` shows a Sonnet-only weekly bucket, so if Anthropic
|
|
23
|
+
// ever surfaces it on stdin we render with a sensible default already.
|
|
24
|
+
seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', usageLabel: 'weekly (Sonnet)' },
|
|
25
|
+
seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', usageLabel: 'weekly (Opus)' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function deriveShort(key) {
|
|
29
|
+
// "five_hour" → "5H"; "seven_day_sonnet" → "7DS"; arbitrary key → uppercase initials
|
|
30
|
+
const m = key.match(/^(\d+)_?([a-z]+)/);
|
|
31
|
+
if (m) {
|
|
32
|
+
const num = m[1];
|
|
33
|
+
const word = m[2];
|
|
34
|
+
const letter = word.charAt(0).toUpperCase();
|
|
35
|
+
const tail = key.slice(m[0].length);
|
|
36
|
+
const suffix = tail
|
|
37
|
+
.split('_')
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((s) => s.charAt(0).toUpperCase())
|
|
40
|
+
.join('');
|
|
41
|
+
return `${num}${letter}${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
return key
|
|
44
|
+
.split('_')
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map((s) => s.charAt(0).toUpperCase())
|
|
47
|
+
.join('') || key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function deriveLong(key) {
|
|
51
|
+
return key
|
|
52
|
+
.split('_')
|
|
53
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
54
|
+
.join(' ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function labelForKey(key) {
|
|
58
|
+
if (KNOWN[key]) return KNOWN[key];
|
|
59
|
+
const short = deriveShort(key);
|
|
60
|
+
return { short, long: deriveLong(key), icon: '⏱', usageLabel: short };
|
|
61
|
+
}
|