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 CHANGED
@@ -242,6 +242,24 @@ Storage paths (cross-platform):
242
242
 
243
243
  Each day's file is plain Markdown — open it in any editor. Transitions are deduped, so the 1Hz statusline refresh doesn't spam.
244
244
 
245
+ ## Model + /usage segments (new in v2.3, generic in v2.4)
246
+
247
+ Three more segments mirror the data Claude Code's `/usage` slash command shows, so you don't have to slash for it every few minutes:
248
+
249
+ | Segment | Icon mode example | Source |
250
+ | ----------- | --------------------- | ------------------------------- |
251
+ | `model` | `🤖 Opus 4.7` | stdin `model.display_name` |
252
+ | `five_hour` | `✦ current ███▒░░ 47% 🔄 21:10` | stdin `rate_limits.five_hour` |
253
+ | `seven_day` | `📅 weekly ▒░░░░░ 9% 🔄 Thu 13:00` | stdin `rate_limits.seven_day` |
254
+
255
+ The window segments stay quiet under 70% (calm emerald), warm to amber at 70–89%, and yield to the leading `🚨 5H █████▓ 94%` / `🚨 7D █████▒ 92%` cap-warn chip at 90%+ — so you never see the same window twice. The gauge is 6 cells wide using a single density family (`█▓▒░`), so the fill→empty boundary reads as one smooth gradient instead of an awkward step between fractional and shaded glyphs. Colors render as a Tailwind-inspired muted palette (emerald-400 / amber-400 / rose-400) on truecolor terminals (`COLORTERM=truecolor`), with a graceful fallback to 8-color ANSI elsewhere. Filter the layout with `--segments=` if you only want a subset:
256
+
257
+ ```bash
258
+ claude-token-saver --statusline --icon --segments=model,five_hour,seven_day,saved
259
+ ```
260
+
261
+ `5h` and `7d` are kept as aliases for the older config files. Any new `rate_limits.*` window Anthropic ships (e.g. a Sonnet-only weekly bucket) renders automatically with a derived label — no config or version bump needed. As of 2026-04-25 the stdin contract exposes only `five_hour` + `seven_day`; the third row in `/usage` ("Current week — Sonnet only") is not in the payload yet, so we mirror what's there.
262
+
245
263
  ## Cap-warn + handoff (new in v2.2)
246
264
 
247
265
  Claude Code's statusline payload now includes rate-limit usage (`rate_limits.five_hour.used_percentage`, `rate_limits.seven_day.used_percentage`). claude-token-saver leads the statusline with a `🚨 5H 94%` (or `🚨 7D 92%`) chip the moment either window crosses **90%**, and writes the transition into history:
package/bin/cli.js CHANGED
@@ -39,6 +39,10 @@ import { dirname, join } from 'node:path';
39
39
  * "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
40
40
  * }
41
41
  * }
42
+ *
43
+ * extractCaps treats `rate_limits` as a generic object so any future window
44
+ * Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
45
+ * changes — known keys get curated labels, unknowns get derived ones.
42
46
  */
43
47
  function readStdinJson() {
44
48
  if (process.stdin.isTTY) return null;
@@ -52,22 +56,34 @@ function readStdinJson() {
52
56
  }
53
57
 
54
58
  function extractCaps(stdinJson) {
55
- if (!stdinJson || !stdinJson.rate_limits) return null;
56
- const rl = stdinJson.rate_limits;
57
- const pick = (obj) => {
58
- if (!obj || typeof obj !== 'object') return null;
59
- const used = Number(obj.used_percentage);
60
- if (!Number.isFinite(used)) return null;
61
- const resetsAt = Number(obj.resets_at);
62
- return {
63
- usedPct: used,
59
+ if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
60
+ const windows = [];
61
+ for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
62
+ if (!value || typeof value !== 'object') continue;
63
+ const usedPct = Number(value.used_percentage);
64
+ if (!Number.isFinite(usedPct)) continue;
65
+ const resetsAt = Number(value.resets_at);
66
+ windows.push({
67
+ key,
68
+ usedPct,
64
69
  resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
65
- };
66
- };
67
- return {
68
- fiveHour: pick(rl.five_hour),
69
- sevenDay: pick(rl.seven_day),
70
- };
70
+ });
71
+ }
72
+ return windows.length ? { windows } : null;
73
+ }
74
+
75
+ /**
76
+ * Pull the human-friendly model name out of Claude Code's stdin payload.
77
+ * `model.display_name` is the contract; fall back to `model.id` when it's
78
+ * absent. Returns null when nothing usable is in the JSON.
79
+ */
80
+ function extractModel(stdinJson) {
81
+ if (!stdinJson || !stdinJson.model) return null;
82
+ const m = stdinJson.model;
83
+ if (typeof m === 'string') return m;
84
+ if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
85
+ if (typeof m.id === 'string' && m.id) return m.id;
86
+ return null;
71
87
  }
72
88
 
73
89
  import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
@@ -108,7 +124,151 @@ function hasFlag(name) {
108
124
  return args.includes(name);
109
125
  }
110
126
 
127
+ /**
128
+ * Scan recent history file contents (newest day first) and return the most
129
+ * recent warning event — `{ time, chip, detail, codes, isCap, capLabel, capPct }`.
130
+ * Returns null when no warning is found in the window.
131
+ *
132
+ * Recognized event lines (from history.js appendDayLine output):
133
+ * - HH:MM:SS ⚠ Cache miss — session abc1: LOW_HIT_RATE
134
+ * - HH:MM:SS ⚠ A → ⚠ B — detail
135
+ * - HH:MM:SS 🚨 5H 94% cap warning (resets in ...)
136
+ * - HH:MM:SS ✓ resolved (was ...) ← skip
137
+ * - HH:MM:SS ✓ 5H cap warning resolved ← skip
138
+ * - HH:MM:SS 📝 handoff written: ... ← skip
139
+ */
140
+ function findLatestWarning(historyEntries, chipToCodes) {
141
+ const warnings = [];
142
+ for (const { date, content } of historyEntries) {
143
+ const lines = content.split('\n');
144
+ for (const line of lines) {
145
+ // Skip non-event lines
146
+ const m = line.match(/^- (\d{2}:\d{2}:\d{2})\s+(.+)$/);
147
+ if (!m) continue;
148
+ const time = m[1];
149
+ const rest = m[2];
150
+ // Skip resolutions and handoff entries
151
+ if (rest.startsWith('✓ ') || rest.startsWith('📝 ')) continue;
152
+ // Cap-warn line: `🚨 5H 94% cap warning (...)`
153
+ const cap = rest.match(/^🚨\s+(\S+)\s+(\d+)%\s+cap warning(?:\s*\((.+)\))?$/);
154
+ if (cap) {
155
+ warnings.push({
156
+ date,
157
+ time,
158
+ chip: `🚨 ${cap[1]} ${cap[2]}%`,
159
+ isCap: true,
160
+ capLabel: cap[1],
161
+ capPct: parseInt(cap[2], 10),
162
+ capReset: cap[3] || null,
163
+ codes: [],
164
+ detail: null,
165
+ });
166
+ continue;
167
+ }
168
+ // Chip line — last token after the chip is `— detail` (optional). The
169
+ // chip itself can be a plain `⚠ X` or a `⚠ A → ⚠ B` transition; we want
170
+ // the *current* chip (right side of the arrow if present).
171
+ const arrowMatch = rest.match(/^(.+?)\s+→\s+(.+?)(?:\s+—\s+(.+))?$/);
172
+ let chip;
173
+ let detail = null;
174
+ if (arrowMatch) {
175
+ chip = arrowMatch[2].trim();
176
+ detail = arrowMatch[3] || null;
177
+ } else {
178
+ const plain = rest.match(/^(\S+(?:\s+\S+)*?)(?:\s+—\s+(.+))?$/);
179
+ if (!plain) continue;
180
+ chip = plain[1].trim();
181
+ detail = plain[2] || null;
182
+ }
183
+ // Resolve codes: detail "session ID: A, B" → codes; else CHIP_TO_CODES.
184
+ const codes = [];
185
+ if (detail) {
186
+ const dm = detail.match(/^session [^:]+:\s*(.+)$/);
187
+ if (dm) {
188
+ for (const c of dm[1].split(',').map((s) => s.trim()).filter(Boolean)) {
189
+ if (!codes.includes(c)) codes.push(c);
190
+ }
191
+ }
192
+ }
193
+ if (chipToCodes[chip]) {
194
+ for (const c of chipToCodes[chip]) if (!codes.includes(c)) codes.push(c);
195
+ }
196
+ warnings.push({ date, time, chip, isCap: false, codes, detail });
197
+ }
198
+ }
199
+ return warnings.length ? warnings[warnings.length - 1] : null;
200
+ }
201
+
111
202
  async function main() {
203
+ // Subcommand: last — print the most recent warning + how to handle it.
204
+ // Designed for the /token-monitor slash command and the auto-skill so the
205
+ // user immediately sees "what just fired and how to fix it" without having
206
+ // to read the whole history file.
207
+ // claude-token-saver last # search last 1 day
208
+ // claude-token-saver last --days 7 # widen the lookback
209
+ if (args[0] === 'last') {
210
+ const { readRecent, historyDir } = await import('../src/history.js');
211
+ const { ISSUE_MESSAGES, CHIP_TO_CODES, CAP_TIPS } = await import('../src/advice.js');
212
+ const days = parseFloat(getArg('--days') || '1');
213
+ const recent = readRecent(days);
214
+ const latest = findLatestWarning(recent, CHIP_TO_CODES);
215
+ if (!latest) {
216
+ console.log(`No warnings in the last ${days} day${days === 1 ? '' : 's'}.`);
217
+ console.log(`(History dir: ${historyDir()})`);
218
+ return;
219
+ }
220
+ // Header
221
+ console.log(`Most recent warning — ${latest.date} ${latest.time}`);
222
+ console.log(` ${latest.chip}${latest.detail ? ` — ${latest.detail}` : ''}`);
223
+ console.log('');
224
+ // Cap-warn path: handoff is the recommendation. Print the bilingual tip
225
+ // and a one-line "how to back up" pointer.
226
+ if (latest.isCap) {
227
+ if (latest.capReset) console.log(` Cap window: ${latest.capReset}`);
228
+ console.log('');
229
+ console.log('💡 ' + CAP_TIPS.en);
230
+ console.log('💡 ' + CAP_TIPS.ko);
231
+ console.log('');
232
+ console.log('Run:');
233
+ console.log(' claude-token-saver handoff');
234
+ return;
235
+ }
236
+ // Chip warning path: render full ISSUE_MESSAGES advice for each code,
237
+ // bilingual (English first, `└ Korean` continuation per line — matches
238
+ // the history.md format).
239
+ if (latest.codes.length === 0) {
240
+ console.log('(No diagnostic code attached — open the table view: `claude-token-saver --days 1`)');
241
+ console.log('(진단 코드 없음 — 표 뷰를 열어보세요: `claude-token-saver --days 1`)');
242
+ return;
243
+ }
244
+ for (const code of latest.codes) {
245
+ const msg = ISSUE_MESSAGES[code];
246
+ if (!msg) {
247
+ console.log(`Code: ${code} (no advice registered)`);
248
+ continue;
249
+ }
250
+ console.log(`▎ ${msg.title}`);
251
+ if (msg.titleKo && msg.titleKo !== msg.title) console.log(` └ ${msg.titleKo}`);
252
+ console.log(` ${msg.explain}`);
253
+ if (msg.explainKo && msg.explainKo !== msg.explain) console.log(` └ ${msg.explainKo}`);
254
+ const actions = typeof msg.actions === 'function' ? msg.actions() : msg.actions || [];
255
+ for (const a of actions) {
256
+ console.log('');
257
+ console.log(` ${a.label}:`);
258
+ if (a.labelKo && a.labelKo !== a.label) console.log(` └ ${a.labelKo}:`);
259
+ const cmds = a.commands || [];
260
+ const cmdsKo = a.commandsKo || [];
261
+ for (let i = 0; i < cmds.length; i++) {
262
+ console.log(` - ${cmds[i]}`);
263
+ const ko = cmdsKo[i];
264
+ if (ko && ko !== cmds[i]) console.log(` └ ${ko}`);
265
+ }
266
+ }
267
+ console.log('');
268
+ }
269
+ return;
270
+ }
271
+
112
272
  // Subcommand: history — print recent warning transitions captured by the
113
273
  // statusline. One markdown file per day, persisted under the platform-
114
274
  // specific user-data dir.
@@ -399,24 +559,30 @@ async function main() {
399
559
  const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
400
560
 
401
561
  // Claude Code feeds the statusline command a JSON blob on stdin every
402
- // refresh. Pull rate_limits out of it so we can surface cap-warn (>=90%)
403
- // chips, record cap transitions, and seed the table view's warning box.
404
- // The table path falls back to the most-recent cached snapshot so the
405
- // /token-monitor slash command (which doesn't pipe stdin) still warns.
562
+ // refresh. Pull rate_limits + model out of it so we can surface cap-warn
563
+ // (>=90%) chips, always-on usage segments, the model chip, record cap
564
+ // transitions, and seed the table view's warning box. The table path falls
565
+ // back to the most-recent cached snapshot so the /token-monitor slash
566
+ // command (which doesn't pipe stdin) still has the data.
406
567
  const stdinJson = readStdinJson();
407
568
  let caps = extractCaps(stdinJson);
408
- if (isStatusline && caps) {
569
+ let model = extractModel(stdinJson);
570
+ if (isStatusline && (caps || model)) {
409
571
  try {
410
- const { persistCaps } = await import('../src/caps-cache.js');
411
- persistCaps(caps);
572
+ const { persistSnapshot } = await import('../src/caps-cache.js');
573
+ persistSnapshot({ caps, model });
412
574
  } catch {
413
575
  // non-critical
414
576
  }
415
577
  }
416
- if (!isStatusline && !caps) {
578
+ if (!isStatusline && (!caps || !model)) {
417
579
  try {
418
- const { loadRecentCaps } = await import('../src/caps-cache.js');
419
- caps = loadRecentCaps();
580
+ const { loadRecentSnapshot } = await import('../src/caps-cache.js');
581
+ const snap = loadRecentSnapshot();
582
+ if (snap) {
583
+ if (!caps && snap.caps) caps = snap.caps;
584
+ if (!model && snap.model) model = snap.model;
585
+ }
420
586
  } catch {
421
587
  // ignore
422
588
  }
@@ -456,9 +622,8 @@ async function main() {
456
622
  recordChip(spikeChip, { detail: chipDetail });
457
623
  // Cap-warn transitions are tracked independently per window — a session
458
624
  // can hit 90% on the 5h window even when no spike chip is firing.
459
- if (caps) {
460
- recordCapTransition('five_hour', caps.fiveHour);
461
- recordCapTransition('seven_day', caps.sevenDay);
625
+ if (caps && Array.isArray(caps.windows)) {
626
+ for (const win of caps.windows) recordCapTransition(win);
462
627
  }
463
628
  } catch {
464
629
  // history is non-critical — don't let it break the statusline render
@@ -498,6 +663,7 @@ async function main() {
498
663
  contextWindow,
499
664
  spikeChip,
500
665
  caps,
666
+ model,
501
667
  };
502
668
 
503
669
  let output;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "2.2.0",
3
+ "version": "2.5.0",
4
4
  "description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/advice.js CHANGED
@@ -26,106 +26,226 @@ function toggleShortcut() {
26
26
  return platformKind() === 'win' ? 'Alt + P' : '⌥ P (mac) / Alt + P (linux)';
27
27
  }
28
28
 
29
+ /**
30
+ * Each entry has parallel English / Korean fields. table.js only reads the
31
+ * English fields (kept stable so the existing report layout doesn't shift).
32
+ * `last` (bin/cli.js) renders both, English first then `└ Korean` continuation
33
+ * — same bilingual style as the history.md file.
34
+ *
35
+ * `commandsKo` is optional per action: when a command is a literal code
36
+ * snippet (e.g. `export CLAUDE_CODE_DISABLE_1M_CONTEXT=1`), there's nothing
37
+ * to translate, so it's fine to omit and fall back to `commands`. When the
38
+ * command is descriptive prose, provide a Korean version.
39
+ */
29
40
  export const ISSUE_MESSAGES = {
30
41
  LARGE_INPUT_PER_REQUEST: {
31
42
  title: 'Per-request input tokens are unusually large (1M context suspected)',
43
+ titleKo: '요청당 입력 토큰이 비정상적으로 큼 (1M 컨텍스트 의심)',
32
44
  explain:
33
45
  'Since Opus 4.7, 1M context is priced at the standard rate, and Max plans auto-promote ' +
34
46
  'sessions to 1M. Once context goes past 200k, long-context pricing kicks in and cache reuse drops.',
47
+ explainKo:
48
+ 'Opus 4.7부터 1M 컨텍스트가 표준 요금이 되었고, Max 플랜은 세션을 자동으로 1M으로 승격합니다. ' +
49
+ '200k를 넘는 순간 장기 컨텍스트 요금이 적용되며 캐시 재사용률도 떨어집니다.',
35
50
  actions: () => [
36
51
  {
37
52
  label: 'Disable 1M context (env var)',
53
+ labelKo: '1M 컨텍스트 끄기 (환경변수)',
38
54
  commands: disable1mEnvSnippet(),
39
55
  },
40
56
  {
41
57
  label: 'In-session toggle',
58
+ labelKo: '세션 내 즉시 토글',
42
59
  commands: [`Press ${toggleShortcut()} to toggle on/off instantly`],
60
+ commandsKo: [`${toggleShortcut()} 누르면 즉시 on/off 토글`],
43
61
  },
44
62
  {
45
63
  label: '⚠ Known bug #31640',
64
+ labelKo: '⚠ 알려진 버그 #31640',
46
65
  commands: [
47
66
  '/model 200k selection sometimes does not stick — context stays at 1M.',
48
67
  'To force off: set the env var above and restart Claude Code.',
49
68
  ],
69
+ commandsKo: [
70
+ '/model 200k 선택이 가끔 적용되지 않음 — 컨텍스트가 1M으로 유지됨.',
71
+ '강제로 끄려면 위의 환경변수를 설정하고 Claude Code를 재시작.',
72
+ ],
50
73
  },
51
74
  ],
52
75
  },
53
76
  LOW_HIT_RATE: {
54
77
  title: 'Cache hit rate is low',
78
+ titleKo: '캐시 적중률이 낮음',
55
79
  explain:
56
80
  'A low hit rate means the same prompt prefix is being rewritten on every call, ' +
57
81
  'inflating input cost.',
82
+ explainKo:
83
+ '적중률이 낮다는 건 동일한 프롬프트 prefix가 매 호출마다 다시 쓰이고 있다는 뜻이며, ' +
84
+ '입력 비용을 부풀립니다.',
58
85
  actions: () => [
59
86
  {
60
87
  label: 'Avoid opening fresh sessions too often',
88
+ labelKo: '새 세션을 너무 자주 열지 말기',
61
89
  commands: ['Continue the same task in the same session (context switching = cache miss)'],
90
+ commandsKo: ['같은 작업은 같은 세션에서 계속 (컨텍스트 전환 = 캐시 미스)'],
62
91
  },
63
92
  {
64
93
  label: 'Stabilize the prompt prefix',
94
+ labelKo: '프롬프트 prefix 안정화',
65
95
  commands: ['System prompts / tool definitions that change per request invalidate the cache every time'],
96
+ commandsKo: ['요청마다 바뀌는 시스템 프롬프트 / 도구 정의는 매번 캐시를 무효화'],
66
97
  },
67
98
  ],
68
99
  },
69
100
  BUCKET_5M_DOMINANT: {
70
101
  title: 'Most cache writes are landing in the 5-minute TTL bucket',
102
+ titleKo: '캐시 쓰기 대부분이 5분 TTL 버킷에 들어가고 있음',
71
103
  explain:
72
104
  'Pro plan is locked to 5m TTL. Gaps longer than 5 minutes expire the cache and force ' +
73
105
  'a costly rebuild.',
106
+ explainKo:
107
+ 'Pro 플랜은 5분 TTL로 고정됩니다. 5분을 넘는 공백마다 캐시가 만료되고 비싼 재빌드가 강제됩니다.',
74
108
  actions: () => [
75
109
  {
76
110
  label: 'The 5-minute rule',
111
+ labelKo: '5분 규칙',
77
112
  commands: [
78
113
  'Sending any prompt within 5 minutes keeps the prefix cache warm',
79
114
  'Upgrade to Max for the 1h TTL bucket on long tasks',
80
115
  ],
116
+ commandsKo: [
117
+ '5분 이내 어떤 프롬프트든 보내면 prefix 캐시가 유지됨',
118
+ '긴 작업은 Max 플랜으로 업그레이드해서 1시간 TTL 버킷 사용',
119
+ ],
81
120
  },
82
121
  ],
83
122
  },
84
123
  HIGH_OUTPUT_RATIO: {
85
124
  title: 'Output share is abnormally high',
125
+ titleKo: '출력 비중이 비정상적으로 높음',
86
126
  explain:
87
127
  'Output tokens are 5x+ pricier than input. Check whether the agent is regenerating ' +
88
128
  'long content unnecessarily.',
129
+ explainKo:
130
+ '출력 토큰은 입력보다 5배 이상 비쌉니다. 에이전트가 불필요하게 긴 컨텐츠를 ' +
131
+ '반복 생성하고 있지 않은지 확인하세요.',
89
132
  actions: () => [
90
133
  {
91
134
  label: 'Cap output length',
135
+ labelKo: '출력 길이 줄이기',
92
136
  commands: [
93
137
  'Avoid full-file rewrites — prefer the Edit tool',
94
138
  'Move long doc/README generation requests into scripts to shrink output',
95
139
  ],
140
+ commandsKo: [
141
+ '파일 전체 재작성 피하기 — Edit 도구 우선 사용',
142
+ '긴 문서/README 생성 요청은 스크립트로 옮겨 출력 축소',
143
+ ],
96
144
  },
97
145
  ],
98
146
  },
99
147
  HIGH_REQUEST_COUNT: {
100
148
  title: 'API calls in this session are 3x+ the baseline',
149
+ titleKo: '이번 세션의 API 호출이 평소 대비 3배 이상',
101
150
  explain:
102
151
  'Excessive tool calls or retry/loop patterns rebroadcast the prefix on every call, ' +
103
152
  'spiking input cost.',
153
+ explainKo:
154
+ '과도한 도구 호출 또는 재시도/루프 패턴은 매 호출마다 prefix를 재전송해 입력 비용을 폭증시킵니다.',
104
155
  actions: () => [
105
156
  {
106
157
  label: 'Parallel / batch processing',
158
+ labelKo: '병렬 / 배치 처리',
107
159
  commands: ['Bundle independent investigations into one message with multiple tool calls'],
160
+ commandsKo: ['독립적인 조사 작업은 도구 호출 여러 개를 한 메시지로 묶어서 보내기'],
108
161
  },
109
162
  {
110
163
  label: 'Watch for loops',
164
+ labelKo: '루프 감시',
111
165
  commands: ['Check that the agent is not repeating the same test/search in a loop'],
166
+ commandsKo: ['에이전트가 동일한 테스트/검색을 루프로 반복하고 있지 않은지 확인'],
112
167
  },
113
168
  ],
114
169
  },
115
170
  FREQUENT_CACHE_REBUILD: {
116
171
  title: 'Cache writes outweigh cache reads',
172
+ titleKo: '캐시 쓰기가 읽기보다 많음',
117
173
  explain:
118
174
  'The cache is being created but not reused. Common when sessions are short-lived or ' +
119
175
  'restarted after TTL expiry.',
176
+ explainKo:
177
+ '캐시가 만들어지지만 재사용되지 않고 있습니다. 세션이 짧거나 TTL 만료 후 재시작될 때 흔합니다.',
120
178
  actions: () => [
121
179
  {
122
180
  label: 'Check session continuity',
181
+ labelKo: '세션 연속성 점검',
123
182
  commands: ['Continue one task in one Claude Code session'],
183
+ commandsKo: ['하나의 작업은 하나의 Claude Code 세션에서 계속'],
124
184
  },
125
185
  ],
126
186
  },
127
187
  };
128
188
 
189
+ /**
190
+ * One-line action tips per issue code — bilingual. Used by history.js to
191
+ * inline a "what to do" hint right below each warning event in the daily
192
+ * markdown file, so the file alone is enough to answer "what was the warning,
193
+ * and how do I fix it" without re-running the tool.
194
+ *
195
+ * The full multi-step advice still lives in `ISSUE_MESSAGES[code].actions()`;
196
+ * `claude-token-saver last` prints that long form on demand.
197
+ */
198
+ export const ISSUE_TIPS = {
199
+ LARGE_INPUT_PER_REQUEST: {
200
+ en: 'Disable 1M context: `export CLAUDE_CODE_DISABLE_1M_CONTEXT=1` (or ⌥P toggle)',
201
+ ko: '1M 컨텍스트 끄기: `export CLAUDE_CODE_DISABLE_1M_CONTEXT=1` (또는 ⌥P 토글)',
202
+ },
203
+ LOW_HIT_RATE: {
204
+ en: 'Continue same task in same session; keep prompt prefix stable',
205
+ ko: '같은 작업은 같은 세션에서 계속; 프롬프트 prefix 안정 유지',
206
+ },
207
+ BUCKET_5M_DOMINANT: {
208
+ en: 'Send any prompt within 5min to keep cache warm; Max plan unlocks 1h TTL',
209
+ ko: '5분 이내 한 번 더 보내 캐시 유지; Max 플랜은 1시간 TTL 제공',
210
+ },
211
+ HIGH_OUTPUT_RATIO: {
212
+ en: 'Avoid full-file rewrites — prefer Edit tool; move long generations to scripts',
213
+ ko: '파일 전체 재작성 피하기 — Edit 도구 사용; 긴 생성은 스크립트로',
214
+ },
215
+ HIGH_REQUEST_COUNT: {
216
+ en: 'Bundle independent calls into one message; check for retry loops',
217
+ ko: '독립 호출은 한 메시지에 묶기; 재시도 루프 점검',
218
+ },
219
+ FREQUENT_CACHE_REBUILD: {
220
+ en: 'Continue one task in one session — short sessions trigger rebuild',
221
+ ko: '한 작업은 한 세션에서 — 짧은 세션은 재빌드 유발',
222
+ },
223
+ };
224
+
225
+ /**
226
+ * Chip text → diagnostic codes. Some chips fire without a `detail` line that
227
+ * includes the code (e.g. `⚠ 1M ON`, which only carries context info), so we
228
+ * need a fallback so history.js can still surface the right tip.
229
+ */
230
+ export const CHIP_TO_CODES = {
231
+ '⚠ 1M ON': ['LARGE_INPUT_PER_REQUEST'],
232
+ '⚠ Cache miss': ['LOW_HIT_RATE'],
233
+ '⚠ Input spike': ['LARGE_INPUT_PER_REQUEST'],
234
+ '⚠ 5m TTL': ['BUCKET_5M_DOMINANT'],
235
+ '⚠ Rebuild churn': ['FREQUENT_CACHE_REBUILD'],
236
+ '⚠ Output heavy': ['HIGH_OUTPUT_RATIO'],
237
+ '⚠ Call surge': ['HIGH_REQUEST_COUNT'],
238
+ };
239
+
240
+ /**
241
+ * Cap-warn (5h/7d >=90%) advice — bilingual. Always points at `handoff`
242
+ * because once the cap blocks you, you need a fresh session to continue.
243
+ */
244
+ export const CAP_TIPS = {
245
+ en: 'Run `claude-token-saver handoff` to back up state before the cap blocks you',
246
+ ko: '`claude-token-saver handoff` 실행해서 캡 도달 전에 상태 백업',
247
+ };
248
+
129
249
  /**
130
250
  * For the statusline: the single most relevant short chip (1~2 words).
131
251
  * Priority reflects what a user can act on *right now*.
package/src/caps-cache.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Caps cache — the rate-limit numbers only flow through stdin from Claude
3
- * Code's statusline contract, but we want the table view (e.g. invoked by
4
- * `/token-monitor`) to surface the same cap-warn box. So whenever the
5
- * statusline path sees caps it writes them here, and the table path reads
6
- * them back if its own stdin was empty.
2
+ * Statusline snapshot cache — the rate-limit numbers and model name only flow
3
+ * through stdin from Claude Code's statusline contract, but we want the table
4
+ * view (e.g. invoked by `/token-monitor`) to surface the same data. So
5
+ * whenever the statusline path sees them it writes them here, and the table
6
+ * path reads them back if its own stdin was empty.
7
7
  *
8
8
  * Stale data is worse than missing data — if the saved snapshot is older
9
9
  * than `maxAgeMs` the loader returns null and the table view stays quiet.
@@ -20,31 +20,64 @@ function ensureDir() {
20
20
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21
21
  }
22
22
 
23
- export function persistCaps(caps) {
24
- if (!caps) return;
23
+ /**
24
+ * Persist whatever subset of statusline state we have right now. A null/empty
25
+ * snapshot is a no-op so callers don't have to guard.
26
+ *
27
+ * @param {{ caps?: object|null, model?: string|null }|null} snapshot
28
+ */
29
+ export function persistSnapshot(snapshot) {
30
+ if (!snapshot) return;
31
+ const hasCaps = !!snapshot.caps;
32
+ const hasModel = typeof snapshot.model === 'string' && snapshot.model.length > 0;
33
+ if (!hasCaps && !hasModel) return;
25
34
  try {
26
35
  ensureDir();
27
- const payload = { capturedAt: Date.now(), caps };
36
+ const payload = {
37
+ capturedAt: Date.now(),
38
+ caps: snapshot.caps || null,
39
+ model: snapshot.model || null,
40
+ };
28
41
  writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
29
42
  } catch {
30
43
  // best-effort cache, never blocks the statusline
31
44
  }
32
45
  }
33
46
 
47
+ /**
48
+ * v2.3 wrote `caps` as `{ fiveHour, sevenDay }`; v2.4+ writes `{ windows: [...] }`.
49
+ * Convert on read so a returning user's stale snapshot still feeds the table view
50
+ * until the next statusline refresh overwrites it.
51
+ */
52
+ function normalizeCaps(caps) {
53
+ if (!caps || typeof caps !== 'object') return null;
54
+ if (Array.isArray(caps.windows)) return caps;
55
+ const windows = [];
56
+ if (caps.fiveHour && typeof caps.fiveHour === 'object') {
57
+ windows.push({ key: 'five_hour', usedPct: caps.fiveHour.usedPct, resetsAt: caps.fiveHour.resetsAt ?? null });
58
+ }
59
+ if (caps.sevenDay && typeof caps.sevenDay === 'object') {
60
+ windows.push({ key: 'seven_day', usedPct: caps.sevenDay.usedPct, resetsAt: caps.sevenDay.resetsAt ?? null });
61
+ }
62
+ return windows.length ? { windows } : null;
63
+ }
64
+
34
65
  /**
35
66
  * @param {object} [opts]
36
67
  * @param {number} [opts.maxAgeMs=5*60*1000] - drop snapshots older than this.
37
- * @returns {object|null}
68
+ * @returns {{ caps: object|null, model: string|null }|null}
38
69
  */
39
- export function loadRecentCaps({ maxAgeMs = 5 * 60 * 1000 } = {}) {
70
+ export function loadRecentSnapshot({ maxAgeMs = 5 * 60 * 1000 } = {}) {
40
71
  try {
41
72
  if (!existsSync(CACHE_PATH)) return null;
42
73
  const raw = readFileSync(CACHE_PATH, 'utf8');
43
74
  const data = JSON.parse(raw);
44
- if (!data || !data.caps) return null;
45
- if (typeof data.capturedAt !== 'number') return null;
75
+ if (!data || typeof data.capturedAt !== 'number') return null;
46
76
  if (Date.now() - data.capturedAt > maxAgeMs) return null;
47
- return data.caps;
77
+ return {
78
+ caps: normalizeCaps(data.caps),
79
+ model: data.model || null,
80
+ };
48
81
  } catch {
49
82
  return null;
50
83
  }