claude-token-saver 2.2.0 → 2.6.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/handoff.js CHANGED
@@ -16,6 +16,9 @@ import { writeFileSync, existsSync } from 'node:fs';
16
16
  import { execSync } from 'node:child_process';
17
17
  import { join, resolve } from 'node:path';
18
18
 
19
+ import { formatResetIn, formatResetClock } from './format-time.js';
20
+ import { labelForKey } from './window-labels.js';
21
+
19
22
  function pad(n) {
20
23
  return String(n).padStart(2, '0');
21
24
  }
@@ -52,16 +55,6 @@ function gitSnapshot(cwd) {
52
55
  return { branch, head, status };
53
56
  }
54
57
 
55
- function formatResetIn(resetsAt, now = new Date()) {
56
- if (!Number.isFinite(resetsAt)) return null;
57
- const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
58
- if (remaining <= 0) return '0m';
59
- const h = Math.floor(remaining / 3600);
60
- const m = Math.floor((remaining % 3600) / 60);
61
- if (h > 0) return `${h}h ${m}m`;
62
- return `${m}m`;
63
- }
64
-
65
58
  function pickPath(cwd, now) {
66
59
  const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
67
60
  const direct = join(cwd, `${stem}.md`);
@@ -99,15 +92,21 @@ function renderTemplate({ now, cwd, git, caps }) {
99
92
 
100
93
  lines.push('## Cap snapshot');
101
94
  lines.push('');
102
- if (caps) {
103
- const fmtRow = (label, info) => {
104
- if (!info) return `- ${label}: (unknown — stdin had no rate-limit info)`;
105
- const reset = formatResetIn(info.resetsAt, now);
106
- const tail = reset ? `, resets in ${reset}` : '';
107
- return `- ${label}: ${Math.round(info.usedPct)}%${tail}`;
108
- };
109
- lines.push(fmtRow('5-hour window', caps.fiveHour));
110
- lines.push(fmtRow('7-day window', caps.sevenDay));
95
+ if (caps && Array.isArray(caps.windows) && caps.windows.length > 0) {
96
+ for (const win of caps.windows) {
97
+ const label = labelForKey(win.key).long;
98
+ if (!Number.isFinite(win.usedPct)) {
99
+ lines.push(`- ${label}: (unknown)`);
100
+ continue;
101
+ }
102
+ const reset = formatResetIn(win.resetsAt, now);
103
+ const clock = formatResetClock(win.resetsAt, now);
104
+ let tail = '';
105
+ if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
106
+ else if (reset) tail = `, resets in ${reset}`;
107
+ else if (clock) tail = `, resets at ${clock}`;
108
+ lines.push(`- ${label}: ${Math.round(win.usedPct)}%${tail}`);
109
+ }
111
110
  } else {
112
111
  lines.push('- (no cap data — run `handoff` from a Claude Code session for live numbers)');
113
112
  }
@@ -148,7 +147,7 @@ function renderTemplate({ now, cwd, git, caps }) {
148
147
  *
149
148
  * @param {object} [opts]
150
149
  * @param {string} [opts.cwd=process.cwd()]
151
- * @param {object|null} [opts.caps] - { fiveHour, sevenDay } from extractCaps
150
+ * @param {object|null} [opts.caps] - { windows: [...] } from extractCaps
152
151
  * @param {Date} [opts.now=new Date()]
153
152
  * @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
154
153
  */
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
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Installs the Claude Code integration assets:
2
+ * Installs the Claude Code integration asset:
3
3
  * - Skill: ~/.claude/skills/claude-token-saver/SKILL.md
4
- * - Slash: ~/.claude/commands/token-monitor.md
4
+ *
5
+ * v2.6.0 consolidates `/token-monitor` into the skill (was redundant with the
6
+ * auto-trigger). On install we actively remove a legacy
7
+ * ~/.claude/commands/token-monitor.md if present so users don't see two
8
+ * overlapping entry points.
5
9
  *
6
10
  * All paths are resolved with node:path so Windows backslashes and POSIX
7
11
  * forward-slashes are both handled. Directories are created with
@@ -9,7 +13,7 @@
9
13
  * exist on every platform.
10
14
  */
11
15
 
12
- import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
16
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
13
17
  import { join } from 'node:path';
14
18
  import { claudeUserDir } from './paths.js';
15
19
 
@@ -36,18 +40,26 @@ countdown, savings, and (when relevant) a leading warning chip.
36
40
  \`claude-token-saver handoff\`).
37
41
  - The user wants to see the token-usage history file or asks for a summary
38
42
  of recent warnings.
43
+ - The user asks for a quick token report or "current state" check (the
44
+ skill replaces the legacy \`/token-monitor\` slash command — same workflow,
45
+ triggered by intent rather than a typed slash).
39
46
 
40
47
  ## What to do
41
48
 
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
49
+ 1. **Lead with the most recent warning + how to handle it.** Run
50
+ \`claude-token-saver last\` first. It returns the most recent warning event
51
+ (chip + detail + timestamp) plus the full advice block for it. Surface that
52
+ to the user before anything else — this is what they came for.
53
+ 2. **Identify the chip.** If the user pasted a statusline (instead of relying
54
+ on \`last\`), pull out the leading \`⚠ ...\` chip. That maps to a specific
55
+ issue category.
56
+ 3. **Show recent history.** Run \`claude-token-saver history\` (default last 7
57
+ days) to see the chronology of warning transitions. Each entry is timestamped,
58
+ bilingual (English line + 한국어), and includes a \`💡\` action tip inline.
59
+ 4. **Drill down on the live state.** Run \`claude-token-saver --days 1\` (or
48
60
  another window) to render the full table view, which lists per-session
49
61
  spikes and recommended actions.
50
- 4. **Explain the warning** in plain language. Use the chip → cause table:
62
+ 5. **Explain the warning** in plain language. Use the chip → cause table:
51
63
 
52
64
  | Chip | Likely cause |
53
65
  | ------------------ | ----------------------------------------------------- |
@@ -61,7 +73,7 @@ countdown, savings, and (when relevant) a leading warning chip.
61
73
  | \`⚠ Output heavy\` | Output ratio dominates input — inspect long generations. |
62
74
  | \`⚠ Call surge\` | Request count is well above baseline. |
63
75
 
64
- 5. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
76
+ 6. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
65
77
  \`claude-token-saver handoff\` to back up the current work to a
66
78
  \`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
67
79
  session. For 1M ON, mention \`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For
@@ -70,9 +82,12 @@ countdown, savings, and (when relevant) a leading warning chip.
70
82
 
71
83
  ## Useful commands
72
84
 
85
+ - \`claude-token-saver last\` — most recent warning + full advice (start here).
86
+ - \`claude-token-saver last --days 7\` — widen the lookback window.
73
87
  - \`claude-token-saver\` — full table report (default last 1 day).
74
88
  - \`claude-token-saver --days 7\` — wider window.
75
- - \`claude-token-saver history\` — recent warning transitions per day.
89
+ - \`claude-token-saver history\` — recent warning transitions per day, with
90
+ inline \`💡\` action tips.
76
91
  - \`claude-token-saver history --days 30\` — longer history.
77
92
  - \`claude-token-saver handoff\` — write a HANDOFF-*.md template in cwd
78
93
  capturing git status + cap snapshot, so a fresh session can resume cleanly.
@@ -89,38 +104,6 @@ History files live under the OS-appropriate user-data dir:
89
104
  Each day's file is plain Markdown — safe to open in any editor.
90
105
  `;
91
106
 
92
- const COMMAND_BODY = `---
93
- description: Show recent claude-token-saver warning history and a fresh report.
94
- ---
95
-
96
- 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.
98
-
99
- Steps:
100
-
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.
119
-
120
- Keep the summary to ~10 lines. The user can re-run the underlying commands
121
- themselves for the full output.
122
- `;
123
-
124
107
  function writeIfNeeded(file, body, force) {
125
108
  const existed = existsSync(file);
126
109
  if (existed && !force) return { path: file, action: 'exists' };
@@ -135,16 +118,18 @@ export function installSkill({ force = false } = {}) {
135
118
  return writeIfNeeded(file, SKILL_BODY, force);
136
119
  }
137
120
 
138
- export function installCommand({ force = false } = {}) {
139
- const dir = join(claudeUserDir(), 'commands');
140
- const file = join(dir, 'token-monitor.md');
141
- mkdirSync(dir, { recursive: true });
142
- return writeIfNeeded(file, COMMAND_BODY, force);
121
+ // Removes the legacy /token-monitor slash command from prior versions.
122
+ // v2.6.0 consolidated it into the skill — the file would otherwise linger.
123
+ export function removeLegacyCommand() {
124
+ const file = join(claudeUserDir(), 'commands', 'token-monitor.md');
125
+ if (!existsSync(file)) return { path: file, action: 'absent' };
126
+ unlinkSync(file);
127
+ return { path: file, action: 'removed' };
143
128
  }
144
129
 
145
130
  export function installAll({ force = false } = {}) {
146
131
  return {
147
132
  skill: installSkill({ force }),
148
- command: installCommand({ force }),
133
+ legacy: removeLegacyCommand(),
149
134
  };
150
135
  }
@@ -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
+ }