claude-token-saver 2.1.0 → 2.2.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 +32 -23
- package/bin/cli.js +119 -3
- package/package.json +1 -1
- package/src/caps-cache.js +51 -0
- package/src/formatters/statusline.js +68 -6
- package/src/formatters/table.js +42 -2
- package/src/handoff.js +162 -0
- package/src/history.js +143 -8
- package/src/installer.js +28 -14
- package/examples/statusline-with-rz1989s.sh +0 -52
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ v1.5 adds three things on top of the original `claude-cache-monitor`:
|
|
|
23
23
|
|
|
24
24
|
The original functionality still works: cache hit rate, TTL breakdown, cost impact vs. no-cache, TTL countdown timer, and Claude Code statusline integration.
|
|
25
25
|
|
|
26
|
-
**
|
|
26
|
+
**Run it standalone or wire it into Claude Code's statusline.** Use `npx claude-token-saver` as a one-shot report, or wire it into Claude Code's native statusline for an always-on chip. See [Two Ways to Use It](#two-ways-to-use-it).
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
@@ -36,7 +36,7 @@ v1.5 신규:
|
|
|
36
36
|
|
|
37
37
|
기존 기능(캐시 히트율·TTL 분포·비용 절감·TTL 카운트다운·statusline)은 그대로 유지됩니다.
|
|
38
38
|
|
|
39
|
-
**단독 도구로도,
|
|
39
|
+
**단독 도구로도, Claude Code statusline 통합으로도 동작합니다.** `npx claude-token-saver` 한 줄로 진단 리포트만 보거나, 내장 statusline에 연결해 상시 표시할 수 있습니다. 자세한 용법은 [Two Ways to Use It](#two-ways-to-use-it) 참고.
|
|
40
40
|
|
|
41
41
|
## Quick Start
|
|
42
42
|
|
|
@@ -99,15 +99,12 @@ Issue codes detected:
|
|
|
99
99
|
|
|
100
100
|
Remediation commands are chosen from `process.platform` — macOS/Linux/WSL get `~/.zshrc` snippets, Windows gets `setx` and the PowerShell equivalent.
|
|
101
101
|
|
|
102
|
-
##
|
|
103
|
-
|
|
104
|
-
`claude-token-saver` is primarily a **standalone tool**; the plugin mode is just a convenience for users who already run another statusline.
|
|
102
|
+
## Two Ways to Use It
|
|
105
103
|
|
|
106
104
|
| Mode | What you run | When to pick this |
|
|
107
105
|
|---|---|---|
|
|
108
106
|
| **1. Standalone CLI report** | `npx claude-token-saver` | One-off diagnosis. Prints the full report (spikes + cache + cost + trend). Zero setup. |
|
|
109
|
-
| **2.
|
|
110
|
-
| **3. Plugin under another statusline** | `examples/statusline-with-rz1989s.sh` appends our segment to rz1989s or any wrapper script | You already have a rich statusline (repo info, cost, MCP, prayer times, themes) and want to bolt the token-saver segment on the end. |
|
|
107
|
+
| **2. Claude Code statusline** | `claude-token-saver --statusline` wired via `~/.claude/settings.json` | You want the chip (hit rate · TTL countdown · Ctx 200k/1M · spike) visible all the time. |
|
|
111
108
|
|
|
112
109
|
Detail for each mode below.
|
|
113
110
|
|
|
@@ -193,22 +190,6 @@ Works best in **Windows Terminal** or **PowerShell 7+** (ANSI color + emoji). Cl
|
|
|
193
190
|
|
|
194
191
|
Same as Linux — install the package in your WSL Node.js and point to the POSIX sh script.
|
|
195
192
|
|
|
196
|
-
#### Combine with rz1989s/claude-code-statusline
|
|
197
|
-
|
|
198
|
-
If you already use [rz1989s/claude-code-statusline](https://github.com/rz1989s/claude-code-statusline) for its rich layout (repo, cost, MCP, prayer times, themes), drop in [`examples/statusline-with-rz1989s.sh`](examples/statusline-with-rz1989s.sh) to append our cache segment at the end — no conflict, no feature overlap.
|
|
199
|
-
|
|
200
|
-
```json
|
|
201
|
-
{
|
|
202
|
-
"statusLine": {
|
|
203
|
-
"type": "command",
|
|
204
|
-
"command": "bash ~/.claude/statusline-with-rz1989s.sh",
|
|
205
|
-
"refreshInterval": 1
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
---
|
|
211
|
-
|
|
212
193
|
Claude Code calls this every ~300ms on events, plus once per `refreshInterval` second while idle. Colors are emitted when the terminal supports them:
|
|
213
194
|
|
|
214
195
|
- **Hit rate** — 🟢 ≥85% · 🟡 70–85% · 🔴 <70%
|
|
@@ -261,6 +242,34 @@ Storage paths (cross-platform):
|
|
|
261
242
|
|
|
262
243
|
Each day's file is plain Markdown — open it in any editor. Transitions are deduped, so the 1Hz statusline refresh doesn't spam.
|
|
263
244
|
|
|
245
|
+
## Cap-warn + handoff (new in v2.2)
|
|
246
|
+
|
|
247
|
+
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:
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
- 14:32:08 🚨 5H 94% cap warning (resets in 1h 38m)
|
|
251
|
+
- 16:10:21 ✓ 5H cap warning resolved
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
When you see the chip, back up the work in flight before the cap blocks you:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
claude-token-saver handoff
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
That writes `./HANDOFF-YYYY-MM-DD-HHMM.md` in the current directory with:
|
|
261
|
+
|
|
262
|
+
- timestamp, cwd, git branch / HEAD / dirty file list
|
|
263
|
+
- the 5h/7d cap snapshot (and "resets in Hh Mm")
|
|
264
|
+
- empty fillable sections for *what I just did*, *TODO*, *where to pick up next*, *gotchas*
|
|
265
|
+
- a one-line resume prompt for a fresh Claude Code session:
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
Read the most recent HANDOFF-*.md in this directory and continue the work.
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The handoff write is also recorded in history (`📝 handoff written: …`), so `/token-monitor` and `claude-token-saver history` show both the cap-warn and the backup event next to each other.
|
|
272
|
+
|
|
264
273
|
## Hook Setup
|
|
265
274
|
|
|
266
275
|
Automatically logs cache stats on every tool call and alerts when hit rate drops below a threshold.
|
package/bin/cli.js
CHANGED
|
@@ -26,6 +26,50 @@ import { readFileSync } from 'node:fs';
|
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Read the JSON blob Claude Code feeds the statusline command on stdin.
|
|
31
|
+
* Returns null when stdin is a TTY or empty (e.g. user invokes `--statusline`
|
|
32
|
+
* by hand) so callers can fall back to flag/env config.
|
|
33
|
+
*
|
|
34
|
+
* The blob shape (subset we consume):
|
|
35
|
+
* {
|
|
36
|
+
* "transcript_path": "...",
|
|
37
|
+
* "rate_limits": {
|
|
38
|
+
* "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
|
|
39
|
+
* "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
*/
|
|
43
|
+
function readStdinJson() {
|
|
44
|
+
if (process.stdin.isTTY) return null;
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(0, 'utf8');
|
|
47
|
+
if (!raw || !raw.trim()) return null;
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
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,
|
|
64
|
+
resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
fiveHour: pick(rl.five_hour),
|
|
69
|
+
sevenDay: pick(rl.seven_day),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
29
73
|
import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
|
|
30
74
|
import {
|
|
31
75
|
dailyTrend,
|
|
@@ -53,8 +97,11 @@ const PKG_VERSION = (() => {
|
|
|
53
97
|
|
|
54
98
|
function getArg(name) {
|
|
55
99
|
const idx = args.indexOf(name);
|
|
56
|
-
if (idx
|
|
57
|
-
|
|
100
|
+
if (idx !== -1) return args[idx + 1];
|
|
101
|
+
const prefix = `${name}=`;
|
|
102
|
+
const eq = args.find((a) => a.startsWith(prefix));
|
|
103
|
+
if (eq) return eq.slice(prefix.length);
|
|
104
|
+
return undefined;
|
|
58
105
|
}
|
|
59
106
|
|
|
60
107
|
function hasFlag(name) {
|
|
@@ -94,6 +141,34 @@ async function main() {
|
|
|
94
141
|
return;
|
|
95
142
|
}
|
|
96
143
|
|
|
144
|
+
// Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
|
|
145
|
+
// capturing git status + the latest cap snapshot, so a fresh Claude Code
|
|
146
|
+
// session can pick up where this one stopped. Pairs with the cap-warn chip:
|
|
147
|
+
// when statusline shows 🚨 5H 90%+, run this to back up state before the cap
|
|
148
|
+
// hits.
|
|
149
|
+
// claude-token-saver handoff # write to cwd
|
|
150
|
+
// claude-token-saver handoff --cwd PATH # custom directory
|
|
151
|
+
if (args[0] === 'handoff') {
|
|
152
|
+
const { writeHandoff } = await import('../src/handoff.js');
|
|
153
|
+
const { recordHandoff } = await import('../src/history.js');
|
|
154
|
+
const cwd = getArg('--cwd') || process.cwd();
|
|
155
|
+
// Cap data only flows in via stdin (Claude Code statusline contract).
|
|
156
|
+
// Direct CLI invocations won't have it — that's fine, the template will
|
|
157
|
+
// note the gap.
|
|
158
|
+
const stdinJson = readStdinJson();
|
|
159
|
+
const caps = extractCaps(stdinJson);
|
|
160
|
+
const { path, git } = writeHandoff({ cwd, caps });
|
|
161
|
+
try { recordHandoff(path); } catch { /* non-critical */ }
|
|
162
|
+
console.log(`Handoff written: ${path}`);
|
|
163
|
+
if (git) {
|
|
164
|
+
console.log(` git: ${git.branch}${git.head ? ` @ ${git.head}` : ''}${git.status ? ' (dirty)' : ' (clean)'}`);
|
|
165
|
+
}
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log('Fill in the empty sections, then start a new Claude Code session with:');
|
|
168
|
+
console.log(' Read the most recent HANDOFF-*.md in this directory and continue the work.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
97
172
|
// Subcommand: install — write the Claude Code Skill and slash command so
|
|
98
173
|
// /token-monitor and the auto-trigger skill become available without any
|
|
99
174
|
// manual file editing. Cross-platform (uses node:path + node:fs).
|
|
@@ -234,11 +309,16 @@ async function main() {
|
|
|
234
309
|
: (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
|
|
235
310
|
const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
|
|
236
311
|
const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
312
|
+
const segmentsArg = getArg('--segments');
|
|
313
|
+
const segments = segmentsArg
|
|
314
|
+
? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
|
|
315
|
+
: null;
|
|
237
316
|
const out = formatReport(data, {
|
|
238
317
|
color: colorOk,
|
|
239
318
|
verbose: isVerbose,
|
|
240
319
|
timer: showTimer,
|
|
241
320
|
mode: isIcon ? 'icon' : 'text',
|
|
321
|
+
segments,
|
|
242
322
|
});
|
|
243
323
|
// For `cycle` mode, prefix with the scenario label so the screen recorder
|
|
244
324
|
// shows what the viewer is looking at (only when explicitly requested).
|
|
@@ -318,6 +398,30 @@ async function main() {
|
|
|
318
398
|
const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
|
|
319
399
|
const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
|
|
320
400
|
|
|
401
|
+
// 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.
|
|
406
|
+
const stdinJson = readStdinJson();
|
|
407
|
+
let caps = extractCaps(stdinJson);
|
|
408
|
+
if (isStatusline && caps) {
|
|
409
|
+
try {
|
|
410
|
+
const { persistCaps } = await import('../src/caps-cache.js');
|
|
411
|
+
persistCaps(caps);
|
|
412
|
+
} catch {
|
|
413
|
+
// non-critical
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (!isStatusline && !caps) {
|
|
417
|
+
try {
|
|
418
|
+
const { loadRecentCaps } = await import('../src/caps-cache.js');
|
|
419
|
+
caps = loadRecentCaps();
|
|
420
|
+
} catch {
|
|
421
|
+
// ignore
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
321
425
|
// For statusline: attach a single-word chip only when there's something
|
|
322
426
|
// actionable right now. 1M context is always shown; otherwise only fire
|
|
323
427
|
// if the most recent session actually appears in the spike list.
|
|
@@ -348,8 +452,14 @@ async function main() {
|
|
|
348
452
|
// Persist transitions to ~/.config/claude-token-saver/history/YYYY-MM-DD.md
|
|
349
453
|
// so /token-monitor and `claude-token-saver history` can replay them.
|
|
350
454
|
try {
|
|
351
|
-
const { recordChip } = await import('../src/history.js');
|
|
455
|
+
const { recordChip, recordCapTransition } = await import('../src/history.js');
|
|
352
456
|
recordChip(spikeChip, { detail: chipDetail });
|
|
457
|
+
// Cap-warn transitions are tracked independently per window — a session
|
|
458
|
+
// 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);
|
|
462
|
+
}
|
|
353
463
|
} catch {
|
|
354
464
|
// history is non-critical — don't let it break the statusline render
|
|
355
465
|
}
|
|
@@ -387,6 +497,7 @@ async function main() {
|
|
|
387
497
|
spikeReport,
|
|
388
498
|
contextWindow,
|
|
389
499
|
spikeChip,
|
|
500
|
+
caps,
|
|
390
501
|
};
|
|
391
502
|
|
|
392
503
|
let output;
|
|
@@ -412,11 +523,16 @@ async function main() {
|
|
|
412
523
|
const colorOk =
|
|
413
524
|
!hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
414
525
|
|
|
526
|
+
const segmentsArg = getArg('--segments');
|
|
527
|
+
const segments = segmentsArg
|
|
528
|
+
? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
|
|
529
|
+
: null;
|
|
415
530
|
output = formatReport(data, {
|
|
416
531
|
color: colorOk,
|
|
417
532
|
verbose: isVerbose,
|
|
418
533
|
timer: showTimer,
|
|
419
534
|
mode: isIcon ? 'icon' : 'text',
|
|
535
|
+
segments,
|
|
420
536
|
});
|
|
421
537
|
} else {
|
|
422
538
|
const { formatReport } = await import('../src/formatters/table.js');
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Stale data is worse than missing data — if the saved snapshot is older
|
|
9
|
+
* than `maxAgeMs` the loader returns null and the table view stays quiet.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { userDataDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
const CACHE_PATH = join(userDataDir(), 'last-caps.json');
|
|
17
|
+
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
const dir = userDataDir();
|
|
20
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function persistCaps(caps) {
|
|
24
|
+
if (!caps) return;
|
|
25
|
+
try {
|
|
26
|
+
ensureDir();
|
|
27
|
+
const payload = { capturedAt: Date.now(), caps };
|
|
28
|
+
writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
|
|
29
|
+
} catch {
|
|
30
|
+
// best-effort cache, never blocks the statusline
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {number} [opts.maxAgeMs=5*60*1000] - drop snapshots older than this.
|
|
37
|
+
* @returns {object|null}
|
|
38
|
+
*/
|
|
39
|
+
export function loadRecentCaps({ maxAgeMs = 5 * 60 * 1000 } = {}) {
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
42
|
+
const raw = readFileSync(CACHE_PATH, 'utf8');
|
|
43
|
+
const data = JSON.parse(raw);
|
|
44
|
+
if (!data || !data.caps) return null;
|
|
45
|
+
if (typeof data.capturedAt !== 'number') return null;
|
|
46
|
+
if (Date.now() - data.capturedAt > maxAgeMs) return null;
|
|
47
|
+
return data.caps;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -49,6 +49,39 @@ function formatTimer(remainingSec) {
|
|
|
49
49
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Pick the cap-warn chip ({ kind, usedPct, resetsAt, label }) that should
|
|
54
|
+
* surface, or null if neither window is at 90%+. When both windows are warning,
|
|
55
|
+
* the one that resets sooner wins (it's the more imminent block).
|
|
56
|
+
*/
|
|
57
|
+
export function pickCapWarn(caps) {
|
|
58
|
+
if (!caps) return null;
|
|
59
|
+
const candidates = [];
|
|
60
|
+
if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
|
|
61
|
+
candidates.push({
|
|
62
|
+
kind: 'five_hour',
|
|
63
|
+
label: '5H',
|
|
64
|
+
usedPct: caps.fiveHour.usedPct,
|
|
65
|
+
resetsAt: caps.fiveHour.resetsAt,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
|
|
69
|
+
candidates.push({
|
|
70
|
+
kind: 'seven_day',
|
|
71
|
+
label: '7D',
|
|
72
|
+
usedPct: caps.sevenDay.usedPct,
|
|
73
|
+
resetsAt: caps.sevenDay.resetsAt,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (candidates.length === 0) return null;
|
|
77
|
+
candidates.sort((a, b) => {
|
|
78
|
+
const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
|
|
79
|
+
const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
|
|
80
|
+
return ar - br;
|
|
81
|
+
});
|
|
82
|
+
return candidates[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
/**
|
|
53
86
|
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
54
87
|
* @param {object} [opts]
|
|
@@ -56,9 +89,10 @@ function formatTimer(remainingSec) {
|
|
|
56
89
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
57
90
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
58
91
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
92
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, hit, ttl, saved, ctx, period. Null/undefined = all.
|
|
59
93
|
*/
|
|
60
|
-
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
|
|
61
|
-
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
|
|
94
|
+
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
|
|
95
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps } = data;
|
|
62
96
|
const { hitRate } = summary;
|
|
63
97
|
|
|
64
98
|
// Hit rate → color signal
|
|
@@ -170,12 +204,40 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
170
204
|
// Spike chip — one word only, keeps the statusline single-line.
|
|
171
205
|
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
172
206
|
|
|
207
|
+
// Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
|
|
208
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
209
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
210
|
+
// English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
|
|
211
|
+
const capWarn = pickCapWarn(caps);
|
|
212
|
+
let capWarnSeg = null;
|
|
213
|
+
if (capWarn) {
|
|
214
|
+
const pct = Math.round(capWarn.usedPct);
|
|
215
|
+
if (isIcon && verbose) {
|
|
216
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} cap ${pct}%${c(RESET)}`;
|
|
217
|
+
} else if (isIcon) {
|
|
218
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} ${pct}%${c(RESET)}`;
|
|
219
|
+
} else if (verbose) {
|
|
220
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${c(RESET)}`;
|
|
221
|
+
} else {
|
|
222
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} ${pct}%${c(RESET)}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
173
226
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
174
227
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
228
|
+
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
229
|
+
// a single spiking session.
|
|
230
|
+
const allow = segments && segments.length
|
|
231
|
+
? new Set(segments.map((s) => s.toLowerCase()))
|
|
232
|
+
: null;
|
|
233
|
+
const want = (name) => !allow || allow.has(name);
|
|
175
234
|
const segs = [];
|
|
176
|
-
if (
|
|
177
|
-
segs.push(
|
|
178
|
-
if (
|
|
179
|
-
segs.push(
|
|
235
|
+
if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
|
|
236
|
+
if (spikeSeg && want('spike')) segs.push(spikeSeg);
|
|
237
|
+
if (want('hit')) segs.push(hitSeg);
|
|
238
|
+
if (want('ttl')) segs.push(ttlSeg);
|
|
239
|
+
if (want('saved')) segs.push(saveSeg);
|
|
240
|
+
if (ctxSeg && want('ctx')) segs.push(ctxSeg);
|
|
241
|
+
if (want('period')) segs.push(periodSeg);
|
|
180
242
|
return segs.join(' · ');
|
|
181
243
|
}
|
package/src/formatters/table.js
CHANGED
|
@@ -123,7 +123,42 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
123
123
|
return lines;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
function formatResetIn(resetsAt, now = new Date()) {
|
|
127
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
128
|
+
const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
|
|
129
|
+
if (remaining <= 0) return '0m';
|
|
130
|
+
const h = Math.floor(remaining / 3600);
|
|
131
|
+
const m = Math.floor((remaining % 3600) / 60);
|
|
132
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
133
|
+
return `${m}m`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderCapWarnSection(caps) {
|
|
137
|
+
const warning = [];
|
|
138
|
+
if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
|
|
139
|
+
warning.push({ label: '5-hour window', info: caps.fiveHour });
|
|
140
|
+
}
|
|
141
|
+
if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
|
|
142
|
+
warning.push({ label: '7-day window', info: caps.sevenDay });
|
|
143
|
+
}
|
|
144
|
+
if (warning.length === 0) return [];
|
|
145
|
+
const lines = [];
|
|
146
|
+
lines.push(' 🚨 Rate-limit cap is closing in');
|
|
147
|
+
lines.push(` ${'─'.repeat(50)}`);
|
|
148
|
+
for (const { label, info } of warning) {
|
|
149
|
+
const reset = formatResetIn(info.resetsAt);
|
|
150
|
+
const tail = reset ? `, resets in ${reset}` : '';
|
|
151
|
+
lines.push(` • ${label}: ${Math.round(info.usedPct)}% used${tail}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(' Back up work before the cap hits:');
|
|
155
|
+
lines.push(' claude-token-saver handoff');
|
|
156
|
+
lines.push(' (writes a HANDOFF-*.md so a fresh session can pick up.)');
|
|
157
|
+
lines.push('');
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
|
|
127
162
|
const lines = [];
|
|
128
163
|
|
|
129
164
|
// Header
|
|
@@ -133,7 +168,12 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
133
168
|
lines.push(` ${'═'.repeat(50)}`);
|
|
134
169
|
lines.push('');
|
|
135
170
|
|
|
136
|
-
//
|
|
171
|
+
// Cap warning leads — it's the most time-sensitive signal we can show.
|
|
172
|
+
if (caps) {
|
|
173
|
+
lines.push(...renderCapWarnSection(caps));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Spike section goes next — it's what the user acts on.
|
|
137
177
|
if (spikeReport && spikeReport.spikes.length > 0) {
|
|
138
178
|
lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
|
|
139
179
|
}
|
package/src/handoff.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff template — captures enough state at session-cap time that a fresh
|
|
3
|
+
* Claude Code session can pick up the work without a long prelude.
|
|
4
|
+
*
|
|
5
|
+
* Output: `./HANDOFF-YYYY-MM-DD-HHMM.md` in the caller's cwd. We never
|
|
6
|
+
* overwrite — if the path is taken we add a `-N` suffix.
|
|
7
|
+
*
|
|
8
|
+
* What goes in:
|
|
9
|
+
* - Header: timestamp, cwd, git branch / HEAD / dirty file list
|
|
10
|
+
* - Cap snapshot: 5h/7d % and resets-in (when known)
|
|
11
|
+
* - Empty fillable sections the user pastes context into
|
|
12
|
+
* - A one-line resume prompt for the next session
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
function pad(n) {
|
|
20
|
+
return String(n).padStart(2, '0');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ymd(d = new Date()) {
|
|
24
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hhmm(d = new Date()) {
|
|
28
|
+
return `${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeGit(cmd, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return execSync(`git ${cmd}`, {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
}).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gitSnapshot(cwd) {
|
|
44
|
+
// `rev-parse --git-dir` succeeds in any repo, including a freshly-init'd one
|
|
45
|
+
// with no commits yet (where `rev-parse HEAD` would fail). We use it as the
|
|
46
|
+
// "is this a repo?" probe.
|
|
47
|
+
const gitDir = safeGit('rev-parse --git-dir', cwd);
|
|
48
|
+
if (!gitDir) return null;
|
|
49
|
+
const branch = safeGit('rev-parse --abbrev-ref HEAD', cwd) || '(no commits)';
|
|
50
|
+
const head = safeGit('rev-parse --short HEAD', cwd);
|
|
51
|
+
const status = safeGit('status --short', cwd);
|
|
52
|
+
return { branch, head, status };
|
|
53
|
+
}
|
|
54
|
+
|
|
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
|
+
function pickPath(cwd, now) {
|
|
66
|
+
const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
|
|
67
|
+
const direct = join(cwd, `${stem}.md`);
|
|
68
|
+
if (!existsSync(direct)) return direct;
|
|
69
|
+
for (let i = 2; i < 100; i++) {
|
|
70
|
+
const candidate = join(cwd, `${stem}-${i}.md`);
|
|
71
|
+
if (!existsSync(candidate)) return candidate;
|
|
72
|
+
}
|
|
73
|
+
return join(cwd, `${stem}-${Date.now()}.md`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderTemplate({ now, cwd, git, caps }) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
lines.push(`# Handoff — ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push(`Generated by \`claude-token-saver handoff\`.`);
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push('## Context');
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(`- cwd: \`${cwd}\``);
|
|
85
|
+
if (git) {
|
|
86
|
+
lines.push(`- git branch: \`${git.branch}\`${git.head ? ` @ \`${git.head}\`` : ''}`);
|
|
87
|
+
if (git.status) {
|
|
88
|
+
lines.push('- dirty files:');
|
|
89
|
+
lines.push(' ```');
|
|
90
|
+
for (const line of git.status.split('\n')) lines.push(` ${line}`);
|
|
91
|
+
lines.push(' ```');
|
|
92
|
+
} else {
|
|
93
|
+
lines.push('- working tree: clean');
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
lines.push('- git: (not a repo)');
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
lines.push('## Cap snapshot');
|
|
101
|
+
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));
|
|
111
|
+
} else {
|
|
112
|
+
lines.push('- (no cap data — run `handoff` from a Claude Code session for live numbers)');
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
|
|
116
|
+
lines.push('## What I just did');
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push('- _(fill in: 1–3 bullets describing the most recent work)_');
|
|
119
|
+
lines.push('');
|
|
120
|
+
|
|
121
|
+
lines.push('## What\'s left (TODO)');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('- [ ] _(fill in)_');
|
|
124
|
+
lines.push('');
|
|
125
|
+
|
|
126
|
+
lines.push('## Where to pick up next');
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('- _(file paths, function names, the exact next step)_');
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
lines.push('## Watch out for');
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
|
|
134
|
+
lines.push('');
|
|
135
|
+
|
|
136
|
+
lines.push('## Resume prompt for the next Claude Code session');
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push('```');
|
|
139
|
+
lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
|
|
140
|
+
lines.push('```');
|
|
141
|
+
lines.push('');
|
|
142
|
+
|
|
143
|
+
return lines.join('\n') + '\n';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Write a handoff file in the given cwd.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} [opts]
|
|
150
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
151
|
+
* @param {object|null} [opts.caps] - { fiveHour, sevenDay } from extractCaps
|
|
152
|
+
* @param {Date} [opts.now=new Date()]
|
|
153
|
+
* @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
|
|
154
|
+
*/
|
|
155
|
+
export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
|
|
156
|
+
const absCwd = resolve(cwd);
|
|
157
|
+
const git = gitSnapshot(absCwd);
|
|
158
|
+
const path = pickPath(absCwd, now);
|
|
159
|
+
const body = renderTemplate({ now, cwd: absCwd, git, caps });
|
|
160
|
+
writeFileSync(path, body);
|
|
161
|
+
return { path, git };
|
|
162
|
+
}
|
package/src/history.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* (none → warning, warning A → warning B, warning → none). One markdown file
|
|
4
4
|
* per calendar day so users can pinpoint "when did this start" easily.
|
|
5
5
|
*
|
|
6
|
+
* Each event is written bilingually: the canonical English line first, the
|
|
7
|
+
* Korean translation as an indented "└" continuation right below. The chip
|
|
8
|
+
* text itself stays as-is (its symbol+English is part of the UX surface), but
|
|
9
|
+
* the diagnostic detail and resolved-status verbs are translated.
|
|
10
|
+
*
|
|
6
11
|
* Storage path is platform-aware (see paths.userDataDir):
|
|
7
12
|
* Windows: %APPDATA%\claude-token-saver\history\YYYY-MM-DD.md
|
|
8
13
|
* macOS: ~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md
|
|
@@ -53,14 +58,70 @@ function saveState(state) {
|
|
|
53
58
|
writeFileSync(STATE_PATH, JSON.stringify(state) + '\n');
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Map a chip's English label to its Korean equivalent.
|
|
63
|
+
* Returns the input unchanged if no mapping is registered (forward-compat
|
|
64
|
+
* with chips added in advice.js after this map was last updated).
|
|
65
|
+
*/
|
|
66
|
+
function chipKo(chip) {
|
|
67
|
+
if (!chip) return chip;
|
|
68
|
+
const map = {
|
|
69
|
+
'⚠ 1M ON': '⚠ 1M 컨텍스트 활성',
|
|
70
|
+
'⚠ Cache miss': '⚠ 캐시 미스',
|
|
71
|
+
'⚠ Rebuild churn': '⚠ 캐시 재빌드 빈발',
|
|
72
|
+
'⚠ Input spike': '⚠ 입력 급증',
|
|
73
|
+
'⚠ Output heavy': '⚠ 출력 과다',
|
|
74
|
+
'⚠ Call surge': '⚠ 호출 급증',
|
|
75
|
+
'⚠ 5m TTL': '⚠ 5분 TTL',
|
|
76
|
+
'⏳ Cache expires': '⏳ 캐시 만료 임박',
|
|
77
|
+
'💰 Cache saved': '💰 캐시 절약',
|
|
78
|
+
'🧠 Cache hit': '🧠 캐시 적중',
|
|
79
|
+
};
|
|
80
|
+
return map[chip] || chip;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Translate the diagnostic detail string to Korean. The detail is constructed
|
|
85
|
+
* in cli.js and follows two stable shapes:
|
|
86
|
+
* "Context auto-promoted to 1M (max single-request {N}k tokens)"
|
|
87
|
+
* "session {ID}: CODE_A, CODE_B"
|
|
88
|
+
* Anything else falls through unchanged.
|
|
89
|
+
*/
|
|
90
|
+
function detailKo(detail) {
|
|
91
|
+
if (!detail) return detail;
|
|
92
|
+
const m1 = detail.match(/^Context auto-promoted to 1M \(max single-request (\d+)k tokens\)$/);
|
|
93
|
+
if (m1) return `1M 컨텍스트 자동 활성 (단일 요청 최대 ${m1[1]}k 토큰)`;
|
|
94
|
+
const m2 = detail.match(/^session ([^:]+): (.+)$/);
|
|
95
|
+
if (m2) {
|
|
96
|
+
const codeKo = {
|
|
97
|
+
LOW_HIT_RATE: '캐시 적중률 낮음',
|
|
98
|
+
FREQUENT_CACHE_REBUILD: '캐시 재빌드 빈발',
|
|
99
|
+
OUTPUT_HEAVY: '출력 과다',
|
|
100
|
+
INPUT_SPIKE: '입력 급증',
|
|
101
|
+
CALL_SURGE: '호출 급증',
|
|
102
|
+
TTL_5M: '5분 TTL',
|
|
103
|
+
};
|
|
104
|
+
const codes = m2[2]
|
|
105
|
+
.split(',')
|
|
106
|
+
.map((c) => c.trim())
|
|
107
|
+
.map((c) => codeKo[c] || c)
|
|
108
|
+
.join(', ');
|
|
109
|
+
return `세션 ${m2[1]}: ${codes}`;
|
|
110
|
+
}
|
|
111
|
+
return detail;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function appendDayLine(en, ko, date = new Date()) {
|
|
57
115
|
ensureDir(HISTORY_DIR);
|
|
58
116
|
const path = join(HISTORY_DIR, `${ymd(date)}.md`);
|
|
117
|
+
const block = ko && ko !== en ? `${en}\n └ ${ko}\n` : `${en}\n`;
|
|
59
118
|
if (!existsSync(path)) {
|
|
60
|
-
|
|
119
|
+
const header = `# Token Monitor / 토큰 모니터 — ${ymd(date)}\n\n## Events / 이벤트\n`;
|
|
120
|
+
writeFileSync(path, header + block);
|
|
61
121
|
} else {
|
|
62
122
|
const existing = readFileSync(path, 'utf8');
|
|
63
|
-
|
|
123
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
124
|
+
writeFileSync(path, existing + sep + block);
|
|
64
125
|
}
|
|
65
126
|
}
|
|
66
127
|
|
|
@@ -77,16 +138,23 @@ export function recordChip(chip, contextHints = {}) {
|
|
|
77
138
|
|
|
78
139
|
if (current === last) return false;
|
|
79
140
|
|
|
80
|
-
|
|
141
|
+
const detail = contextHints.detail || null;
|
|
142
|
+
const detailEn = detail ? ` — ${detail}` : '';
|
|
143
|
+
const detailKr = detail ? ` — ${detailKo(detail)}` : '';
|
|
144
|
+
|
|
145
|
+
let en, ko;
|
|
81
146
|
if (current && !last) {
|
|
82
|
-
|
|
147
|
+
en = `- ${hms(now)} ${current}${detailEn}`;
|
|
148
|
+
ko = `${chipKo(current)}${detailKr}`;
|
|
83
149
|
} else if (current && last) {
|
|
84
|
-
|
|
150
|
+
en = `- ${hms(now)} ${last} → ${current}${detailEn}`;
|
|
151
|
+
ko = `${chipKo(last)} → ${chipKo(current)}${detailKr}`;
|
|
85
152
|
} else {
|
|
86
153
|
// current === null, last was something — warning resolved
|
|
87
|
-
|
|
154
|
+
en = `- ${hms(now)} ✓ resolved (was ${last})`;
|
|
155
|
+
ko = `✓ 해소됨 (이전: ${chipKo(last)})`;
|
|
88
156
|
}
|
|
89
|
-
appendDayLine(
|
|
157
|
+
appendDayLine(en, ko, now);
|
|
90
158
|
saveState({ chip: current, ts: now.toISOString() });
|
|
91
159
|
return true;
|
|
92
160
|
}
|
|
@@ -110,6 +178,73 @@ export function readRecent(days = 7) {
|
|
|
110
178
|
return out;
|
|
111
179
|
}
|
|
112
180
|
|
|
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
|
+
/**
|
|
196
|
+
* 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.
|
|
199
|
+
*
|
|
200
|
+
* @param {'five_hour'|'seven_day'} kind
|
|
201
|
+
* @param {{ usedPct: number, resetsAt: number|null } | null} info
|
|
202
|
+
* @returns {boolean} true when a line was appended
|
|
203
|
+
*/
|
|
204
|
+
export function recordCapTransition(kind, info) {
|
|
205
|
+
const state = loadState();
|
|
206
|
+
const slotKey = `cap_${kind}`;
|
|
207
|
+
const wasWarn = !!state[slotKey];
|
|
208
|
+
const isWarn = !!(info && Number.isFinite(info.usedPct) && info.usedPct >= 90);
|
|
209
|
+
if (wasWarn === isWarn) return false;
|
|
210
|
+
|
|
211
|
+
const now = new Date();
|
|
212
|
+
const labelEn = kind === 'five_hour' ? '5H' : '7D';
|
|
213
|
+
const labelKo = kind === 'five_hour' ? '5시간 윈도' : '7일 윈도';
|
|
214
|
+
let en;
|
|
215
|
+
let ko;
|
|
216
|
+
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})` : '';
|
|
221
|
+
en = `- ${hms(now)} 🚨 ${labelEn} ${pct}% cap warning${tail}`;
|
|
222
|
+
ko = `🚨 ${labelKo} ${pct}% 캡 경고${tailKo}`;
|
|
223
|
+
} else {
|
|
224
|
+
en = `- ${hms(now)} ✓ ${labelEn} cap warning resolved`;
|
|
225
|
+
ko = `✓ ${labelKo} 캡 경고 해소`;
|
|
226
|
+
}
|
|
227
|
+
appendDayLine(en, ko, now);
|
|
228
|
+
state[slotKey] = isWarn;
|
|
229
|
+
saveState(state);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Record a handoff write — invoked by the `handoff` subcommand so
|
|
235
|
+
* `claude-token-saver history` shows when work was backed up to a HANDOFF file.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} filePath
|
|
238
|
+
* @returns {boolean}
|
|
239
|
+
*/
|
|
240
|
+
export function recordHandoff(filePath) {
|
|
241
|
+
const now = new Date();
|
|
242
|
+
const en = `- ${hms(now)} 📝 handoff written: ${filePath}`;
|
|
243
|
+
const ko = `📝 핸드오프 백업 작성: ${filePath}`;
|
|
244
|
+
appendDayLine(en, ko, now);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
113
248
|
/**
|
|
114
249
|
* List all available history file dates (sorted newest first).
|
|
115
250
|
*/
|
package/src/installer.js
CHANGED
|
@@ -15,7 +15,7 @@ import { claudeUserDir } from './paths.js';
|
|
|
15
15
|
|
|
16
16
|
const SKILL_BODY = `---
|
|
17
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
|
|
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, rate-limit caps (5h/7d), or anything in the statusline produced by claude-token-saver (chips like "🚨 5H 94%", "🚨 7D 92%", "⚠ 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, want to understand a warning they just saw, or want to back up work before a session cap with \`claude-token-saver handoff\`.
|
|
19
19
|
---
|
|
20
20
|
|
|
21
21
|
# claude-token-saver — Claude Code Token Monitor
|
|
@@ -26,11 +26,14 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
26
26
|
|
|
27
27
|
## When this skill should activate
|
|
28
28
|
|
|
29
|
-
- The user references any chip wording:
|
|
30
|
-
\`⚠
|
|
31
|
-
\`⚠ Call surge\`.
|
|
29
|
+
- The user references any chip wording: \`🚨 5H NN%\`, \`🚨 7D NN%\`,
|
|
30
|
+
\`⚠ 1M ON\`, \`⚠ Input spike\`, \`⚠ Cache miss\`, \`⚠ 5m TTL\`,
|
|
31
|
+
\`⚠ Rebuild churn\`, \`⚠ Output heavy\`, \`⚠ Call surge\`.
|
|
32
32
|
- The user asks "why is my cache hit rate low", "what does this warning mean",
|
|
33
33
|
"when did this start happening", or similar.
|
|
34
|
+
- The user is approaching a rate-limit cap and wants to back up the current
|
|
35
|
+
work so a fresh session can continue (point them at
|
|
36
|
+
\`claude-token-saver handoff\`).
|
|
34
37
|
- The user wants to see the token-usage history file or asks for a summary
|
|
35
38
|
of recent warnings.
|
|
36
39
|
|
|
@@ -48,6 +51,8 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
48
51
|
|
|
49
52
|
| Chip | Likely cause |
|
|
50
53
|
| ------------------ | ----------------------------------------------------- |
|
|
54
|
+
| \`🚨 5H NN%\` | 5-hour rate-limit window at NN% (>=90%). Cap is imminent. |
|
|
55
|
+
| \`🚨 7D NN%\` | 7-day rate-limit window at NN% (>=90%). Pace yourself. |
|
|
51
56
|
| \`⚠ 1M ON\` | Auto-promoted to 1M context (Opus 4.7+ Max default). |
|
|
52
57
|
| \`⚠ Input spike\` | One request consumed >250k or >3× the recent p95. |
|
|
53
58
|
| \`⚠ Cache miss\` | Cache hit rate dropped below ~70%. |
|
|
@@ -56,10 +61,12 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
56
61
|
| \`⚠ Output heavy\` | Output ratio dominates input — inspect long generations. |
|
|
57
62
|
| \`⚠ Call surge\` | Request count is well above baseline. |
|
|
58
63
|
|
|
59
|
-
5. **Suggest the next action.** For
|
|
60
|
-
\`
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
5. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
|
|
65
|
+
\`claude-token-saver handoff\` to back up the current work to a
|
|
66
|
+
\`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
|
|
67
|
+
session. For 1M ON, mention \`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For
|
|
68
|
+
5m TTL, point at the Max plan's 1h bucket. For input spike, suggest
|
|
69
|
+
splitting the conversation or compacting context.
|
|
63
70
|
|
|
64
71
|
## Useful commands
|
|
65
72
|
|
|
@@ -67,6 +74,8 @@ countdown, savings, and (when relevant) a leading warning chip.
|
|
|
67
74
|
- \`claude-token-saver --days 7\` — wider window.
|
|
68
75
|
- \`claude-token-saver history\` — recent warning transitions per day.
|
|
69
76
|
- \`claude-token-saver history --days 30\` — longer history.
|
|
77
|
+
- \`claude-token-saver handoff\` — write a HANDOFF-*.md template in cwd
|
|
78
|
+
capturing git status + cap snapshot, so a fresh session can resume cleanly.
|
|
70
79
|
- \`claude-token-saver mode\` — show statusline preferences.
|
|
71
80
|
- \`claude-token-saver mode icon verbose 1d\` — change preferences.
|
|
72
81
|
|
|
@@ -90,18 +99,23 @@ quick read of their Claude Code token usage and any active warnings.
|
|
|
90
99
|
Steps:
|
|
91
100
|
|
|
92
101
|
1. Run \`claude-token-saver history --days 7\` and capture the output. This
|
|
93
|
-
prints recent warning transitions (timestamps + chip + short detail)
|
|
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.
|
|
94
105
|
2. Run \`claude-token-saver --days 1\` and capture the output. This prints the
|
|
95
106
|
full table view: TTL breakdown, cost impact, daily trend, and any active
|
|
96
|
-
spikes with recommended actions.
|
|
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.
|
|
97
109
|
3. Summarize for the user:
|
|
98
110
|
- **Active warnings** — list the most recent unresolved chip(s) with the
|
|
99
|
-
time they appeared.
|
|
111
|
+
time they appeared. Cap-warn (\`🚨 5H/7D NN%\`) outranks everything else.
|
|
100
112
|
- **Today's pattern** — when warnings cluster in time, mention it.
|
|
101
|
-
- **Recommended action** —
|
|
102
|
-
|
|
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.
|
|
103
117
|
4. If the history is empty, say so plainly — no warnings means the cache has
|
|
104
|
-
been healthy in the configured window.
|
|
118
|
+
been healthy and no caps were close in the configured window.
|
|
105
119
|
|
|
106
120
|
Keep the summary to ~10 lines. The user can re-run the underlying commands
|
|
107
121
|
themselves for the full output.
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Combine rz1989s/claude-code-statusline (rich layout: repo, cost, MCP, prayer times)
|
|
3
|
-
# with claude-token-saver (cache hit rate, TTL countdown, 1M-context detection,
|
|
4
|
-
# spike diagnosis). The two projects don't overlap — rz1989s runs first, our
|
|
5
|
-
# cache chip is appended as the final segment.
|
|
6
|
-
#
|
|
7
|
-
# Install:
|
|
8
|
-
# 1) Follow rz1989s install instructions so bash ~/.claude/statusline.sh works:
|
|
9
|
-
# https://github.com/rz1989s/claude-code-statusline
|
|
10
|
-
# 2) npm install -g claude-token-saver (or rely on npx — fallback below)
|
|
11
|
-
# 3) Save this file as: ~/.claude/statusline-with-rz1989s.sh
|
|
12
|
-
# chmod +x ~/.claude/statusline-with-rz1989s.sh
|
|
13
|
-
# 4) In ~/.claude/settings.json:
|
|
14
|
-
# {
|
|
15
|
-
# "statusLine": {
|
|
16
|
-
# "type": "command",
|
|
17
|
-
# "command": "bash ~/.claude/statusline-with-rz1989s.sh",
|
|
18
|
-
# "refreshInterval": 1
|
|
19
|
-
# }
|
|
20
|
-
# }
|
|
21
|
-
#
|
|
22
|
-
# refreshInterval: 1 keeps our TTL countdown ticking while you're idle.
|
|
23
|
-
# Drop to 2 or 5 for lower local CPU if your rz1989s config does heavy work.
|
|
24
|
-
|
|
25
|
-
# Claude Code sends the session JSON on stdin. Both tools want to read it,
|
|
26
|
-
# so we buffer it and tee to each.
|
|
27
|
-
input=$(cat)
|
|
28
|
-
|
|
29
|
-
# --- 1) rz1989s layout (if installed) ---
|
|
30
|
-
RZ_STATUSLINE="${CLAUDE_RZ_STATUSLINE:-$HOME/.claude/statusline.sh}"
|
|
31
|
-
if [ -f "$RZ_STATUSLINE" ]; then
|
|
32
|
-
printf '%s' "$input" | bash "$RZ_STATUSLINE"
|
|
33
|
-
# Separator between the two tools. Dim pipe.
|
|
34
|
-
printf ' \033[90m|\033[00m '
|
|
35
|
-
fi
|
|
36
|
-
|
|
37
|
-
# --- 2) claude-token-saver ---
|
|
38
|
-
# Pass --exclude-session so the current session's tool calls don't reset the
|
|
39
|
-
# TTL countdown. The path comes from the session JSON if present.
|
|
40
|
-
session_path=$(printf '%s' "$input" | sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\.jsonl\)".*/\1/p' | head -n1)
|
|
41
|
-
exclude_flag=""
|
|
42
|
-
if [ -n "$session_path" ]; then
|
|
43
|
-
exclude_flag="--exclude-session $session_path"
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
|
-
if command -v claude-token-saver >/dev/null 2>&1; then
|
|
47
|
-
# shellcheck disable=SC2086
|
|
48
|
-
claude-token-saver --statusline --icon $exclude_flag 2>/dev/null || true
|
|
49
|
-
else
|
|
50
|
-
# shellcheck disable=SC2086
|
|
51
|
-
npx --yes claude-token-saver@latest --statusline --icon $exclude_flag 2>/dev/null || true
|
|
52
|
-
fi
|