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/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
- function appendDayLine(en, ko, date = new Date()) {
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
- const block = ko && ko !== en ? `${en}\n └ ${ko}\n` : `${en}\n`;
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`) has its own dedup slot, so
198
- * the daily file gets two transitions max per window per warning episode.
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 {'five_hour'|'seven_day'} kind
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(kind, info) {
244
+ export function recordCapTransition(window) {
245
+ if (!window || typeof window.key !== 'string') return false;
205
246
  const state = loadState();
206
- const slotKey = `cap_${kind}`;
247
+ const slotKey = `cap_${window.key}`;
207
248
  const wasWarn = !!state[slotKey];
208
- const isWarn = !!(info && Number.isFinite(info.usedPct) && info.usedPct >= 90);
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 labelEn = kind === 'five_hour' ? '5H' : '7D';
213
- const labelKo = kind === 'five_hour' ? '5시간 윈도' : '7일 윈도';
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(info.usedPct);
218
- const reset = formatResetIn(info.resetsAt, now);
219
- const tail = reset ? ` (resets in ${reset})` : '';
220
- const tailKo = reset ? ` (리셋까지 ${reset})` : '';
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
- appendDayLine(en, ko, now);
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. **Identify the chip.** If the user pasted a statusline, pull out the leading
43
- \`⚠ ...\` chip. That maps to a specific issue category.
44
- 2. **Show recent history.** Run \`claude-token-saver history\` (default last 7
45
- days) to see the chronology of warning transitions. Each entry is timestamped
46
- and includes a short detail string.
47
- 3. **Drill down on the live state.** Run \`claude-token-saver --days 1\` (or
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
- 4. **Explain the warning** in plain language. Use the chip → cause table:
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
- 5. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
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 history --days 7\` and capture the output. This
102
- prints recent warning transitions (timestamps + chip + short detail),
103
- including any \`🚨 5H NN%\` / \`🚨 7D NN%\` cap-warn entries and any
104
- \`📝 handoff written: ...\` events.
105
- 2. Run \`claude-token-saver --days 1\` and capture the output. This prints the
106
- full table view: TTL breakdown, cost impact, daily trend, and any active
107
- spikes with recommended actions. When a rate-limit cap is at >=90% the
108
- table leads with a "🚨 Rate-limit cap is closing in" section.
109
- 3. Summarize for the user:
110
- - **Active warnings** — list the most recent unresolved chip(s) with the
111
- time they appeared. Cap-warn (\`🚨 5H/7D NN%\`) outranks everything else.
112
- - **Today's pattern** — when warnings cluster in time, mention it.
113
- - **Recommended action** — for cap-warn, point at \`claude-token-saver
114
- handoff\` so the user can back up state before the cap blocks them.
115
- Otherwise pick the highest-leverage suggestion from the table report's
116
- "Recommended actions" section.
117
- 4. If the history is empty, say so plainly — no warnings means the cache has
118
- been healthy and no caps were close in the configured window.
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
+ }