claude-token-saver 2.0.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 +346 -0
- package/bin/cli.js +228 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/examples/statusline-with-rz1989s.sh +52 -0
- package/package.json +37 -0
- package/src/advice.js +138 -0
- package/src/cost.js +150 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +164 -0
- package/src/formatters/table.js +235 -0
- package/src/hook-manager.js +89 -0
- package/src/hook.cjs +170 -0
- package/src/parser.js +197 -0
- package/src/stats.js +346 -0
|
@@ -0,0 +1,52 @@
|
|
|
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
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-token-saver",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-token-saver": "./bin/cli.js",
|
|
8
|
+
"claude-cache-monitor": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"examples/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"cache",
|
|
23
|
+
"monitoring",
|
|
24
|
+
"prompt-caching",
|
|
25
|
+
"cli",
|
|
26
|
+
"statusline",
|
|
27
|
+
"claude-code",
|
|
28
|
+
"token-usage",
|
|
29
|
+
"cost-savings",
|
|
30
|
+
"1m-context"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/rootstudioyaml/claude-token-saver"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/advice.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable advice for each diagnostic issue code.
|
|
3
|
+
* Platform-specific commands are selected from process.platform.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function platformKind() {
|
|
7
|
+
if (process.platform === 'win32') return 'win';
|
|
8
|
+
// WSL shows up as 'linux' but $WSL_DISTRO_NAME is set — treat as linux either way.
|
|
9
|
+
return 'posix';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function disable1mEnvSnippet() {
|
|
13
|
+
if (platformKind() === 'win') {
|
|
14
|
+
return [
|
|
15
|
+
'setx CLAUDE_CODE_DISABLE_1M_CONTEXT 1',
|
|
16
|
+
'(PowerShell) $env:CLAUDE_CODE_DISABLE_1M_CONTEXT = "1"',
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
return [
|
|
20
|
+
"echo 'export CLAUDE_CODE_DISABLE_1M_CONTEXT=1' >> ~/.zshrc && source ~/.zshrc",
|
|
21
|
+
'(bash) echo \'export CLAUDE_CODE_DISABLE_1M_CONTEXT=1\' >> ~/.bashrc && source ~/.bashrc',
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toggleShortcut() {
|
|
26
|
+
return platformKind() === 'win' ? 'Alt + P' : '⌥ P (mac) / Alt + P (linux)';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ISSUE_MESSAGES = {
|
|
30
|
+
LARGE_INPUT_PER_REQUEST: {
|
|
31
|
+
title: '요청당 입력 토큰이 평소보다 매우 큽니다 (1M 컨텍스트 의심)',
|
|
32
|
+
explain:
|
|
33
|
+
'Opus 4.7부터 1M 컨텍스트가 표준 가격으로 풀리면서 Max 플랜은 자동으로 1M로 승격됩니다. ' +
|
|
34
|
+
'한 번 컨텍스트가 200k를 넘으면 long-context 단가가 적용되고 캐시 재사용도 어려워집니다.',
|
|
35
|
+
actions: () => [
|
|
36
|
+
{
|
|
37
|
+
label: '1M 컨텍스트 OFF (환경변수)',
|
|
38
|
+
commands: disable1mEnvSnippet(),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: '세션 내 토글',
|
|
42
|
+
commands: [`단축키 ${toggleShortcut()} 로 즉시 On/Off`],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: '⚠ 알려진 버그 #31640',
|
|
46
|
+
commands: [
|
|
47
|
+
'/model 로 200k 선택해도 컨텍스트가 1M에 머무는 케이스가 있습니다.',
|
|
48
|
+
'확실히 끄려면 위 환경변수를 설정한 뒤 Claude Code를 재시작하세요.',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
LOW_HIT_RATE: {
|
|
54
|
+
title: '캐시 히트율이 낮습니다',
|
|
55
|
+
explain:
|
|
56
|
+
'캐시 히트율이 떨어지면 같은 프롬프트 prefix를 매번 다시 작성하게 되어 입력 비용이 커집니다.',
|
|
57
|
+
actions: () => [
|
|
58
|
+
{
|
|
59
|
+
label: '세션을 너무 자주 새로 열지 않았는지 확인',
|
|
60
|
+
commands: ['한 작업은 같은 세션에서 이어가세요 (컨텍스트 전환 = 캐시 미스)'],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: '프롬프트 prefix 안정화',
|
|
64
|
+
commands: ['시스템 프롬프트·도구 정의가 요청마다 바뀌면 캐시가 매번 무효화됩니다'],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
BUCKET_5M_DOMINANT: {
|
|
69
|
+
title: '5분 TTL 쓰기가 대부분입니다',
|
|
70
|
+
explain:
|
|
71
|
+
'Pro 플랜은 5분 TTL로 고정됩니다. 5분 이상 간격이 벌어지면 캐시가 만료되어 재작성 비용이 발생합니다.',
|
|
72
|
+
actions: () => [
|
|
73
|
+
{
|
|
74
|
+
label: '5분 규칙',
|
|
75
|
+
commands: [
|
|
76
|
+
'5분 안에 아무 프롬프트라도 보내면 prefix 캐시가 유지됩니다',
|
|
77
|
+
'긴 작업이 필요하면 Max 플랜으로 1h TTL 자동 적용',
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
HIGH_OUTPUT_RATIO: {
|
|
83
|
+
title: 'Output 비중이 비정상적으로 높습니다',
|
|
84
|
+
explain:
|
|
85
|
+
'Output 토큰은 입력보다 5배 이상 비쌉니다. 에이전트가 장문을 반복 생성하지 않는지 확인하세요.',
|
|
86
|
+
actions: () => [
|
|
87
|
+
{
|
|
88
|
+
label: '출력 길이 제한',
|
|
89
|
+
commands: [
|
|
90
|
+
'불필요한 전체 파일 쓰기·재생성 지양 (Edit 툴 활용)',
|
|
91
|
+
'긴 문서·README 생성 요청을 스크립트화해서 줄이세요',
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
HIGH_REQUEST_COUNT: {
|
|
97
|
+
title: '세션의 API 호출 수가 평소의 3배 이상입니다',
|
|
98
|
+
explain:
|
|
99
|
+
'툴 호출이 과도하거나 루프/재시도가 많으면 호출당 prefix 재전송으로 입력 비용이 폭증합니다.',
|
|
100
|
+
actions: () => [
|
|
101
|
+
{
|
|
102
|
+
label: '병렬/일괄 처리',
|
|
103
|
+
commands: ['독립적인 조사는 한 메시지에 여러 도구 호출로 묶으세요'],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: '루프 감지',
|
|
107
|
+
commands: ['같은 테스트·검색을 반복하는 에이전트 루프가 없는지 확인'],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
FREQUENT_CACHE_REBUILD: {
|
|
112
|
+
title: '캐시 쓰기가 읽기보다 많습니다',
|
|
113
|
+
explain:
|
|
114
|
+
'캐시를 만들고 재사용하지 못하고 있습니다. 세션이 자주 끊기거나 TTL이 만료된 후 새로 시작한 경우 흔합니다.',
|
|
115
|
+
actions: () => [
|
|
116
|
+
{
|
|
117
|
+
label: '세션 지속 시간 확인',
|
|
118
|
+
commands: ['한 작업은 같은 Claude Code 세션에서 이어가세요'],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* For the statusline: the single most relevant short chip (1~2 words).
|
|
126
|
+
* Priority reflects what a user can act on *right now*.
|
|
127
|
+
*/
|
|
128
|
+
export function chipForIssues(issues, contextWindow) {
|
|
129
|
+
if (contextWindow?.size === '1M') return '⚠ 1M컨텍스트';
|
|
130
|
+
const codes = issues.map((i) => i.code);
|
|
131
|
+
if (codes.includes('LARGE_INPUT_PER_REQUEST')) return '⚠ 입력폭주';
|
|
132
|
+
if (codes.includes('BUCKET_5M_DOMINANT')) return '⚠ 5m TTL';
|
|
133
|
+
if (codes.includes('LOW_HIT_RATE')) return '⚠ 캐시미스';
|
|
134
|
+
if (codes.includes('FREQUENT_CACHE_REBUILD')) return '⚠ 재작성';
|
|
135
|
+
if (codes.includes('HIGH_OUTPUT_RATIO')) return '⚠ 출력과다';
|
|
136
|
+
if (codes.includes('HIGH_REQUEST_COUNT')) return '⚠ 호출폭주';
|
|
137
|
+
return null;
|
|
138
|
+
}
|
package/src/cost.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost impact estimation based on Anthropic pricing.
|
|
3
|
+
* Source: https://docs.claude.com/en/docs/about-claude/pricing
|
|
4
|
+
* Prices per million tokens (USD). Updated 2026-04 for Opus 4.7 release.
|
|
5
|
+
*
|
|
6
|
+
* Note: Opus 4.5/4.6/4.7 use reduced pricing ($5/$25) vs. older Opus 4/4.1 ($15/$75).
|
|
7
|
+
* Cache writes are now tracked separately for 5m and 1h TTLs, each with their own rate.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PRICING = {
|
|
11
|
+
// Opus 4.5+ (new pricing tier — includes 4.5, 4.6, 4.7, and future)
|
|
12
|
+
'claude-opus-new': {
|
|
13
|
+
input: 5.0,
|
|
14
|
+
cacheWrite5m: 6.25,
|
|
15
|
+
cacheWrite1h: 10.0,
|
|
16
|
+
cacheRead: 0.5,
|
|
17
|
+
output: 25.0,
|
|
18
|
+
},
|
|
19
|
+
// Opus 4 / 4.1 / Opus 3 (legacy premium pricing)
|
|
20
|
+
'claude-opus-legacy': {
|
|
21
|
+
input: 15.0,
|
|
22
|
+
cacheWrite5m: 18.75,
|
|
23
|
+
cacheWrite1h: 30.0,
|
|
24
|
+
cacheRead: 1.5,
|
|
25
|
+
output: 75.0,
|
|
26
|
+
},
|
|
27
|
+
// Sonnet 4 / 4.5 / 4.6 / 3.7
|
|
28
|
+
'claude-sonnet': {
|
|
29
|
+
input: 3.0,
|
|
30
|
+
cacheWrite5m: 3.75,
|
|
31
|
+
cacheWrite1h: 6.0,
|
|
32
|
+
cacheRead: 0.3,
|
|
33
|
+
output: 15.0,
|
|
34
|
+
},
|
|
35
|
+
// Haiku 4.5
|
|
36
|
+
'claude-haiku-4-5': {
|
|
37
|
+
input: 1.0,
|
|
38
|
+
cacheWrite5m: 1.25,
|
|
39
|
+
cacheWrite1h: 2.0,
|
|
40
|
+
cacheRead: 0.1,
|
|
41
|
+
output: 5.0,
|
|
42
|
+
},
|
|
43
|
+
// Haiku 3.5
|
|
44
|
+
'claude-haiku-3-5': {
|
|
45
|
+
input: 0.8,
|
|
46
|
+
cacheWrite5m: 1.0,
|
|
47
|
+
cacheWrite1h: 1.6,
|
|
48
|
+
cacheRead: 0.08,
|
|
49
|
+
output: 4.0,
|
|
50
|
+
},
|
|
51
|
+
// Haiku 3 (deprecated)
|
|
52
|
+
'claude-haiku-3': {
|
|
53
|
+
input: 0.25,
|
|
54
|
+
cacheWrite5m: 0.3,
|
|
55
|
+
cacheWrite1h: 0.5,
|
|
56
|
+
cacheRead: 0.03,
|
|
57
|
+
output: 1.25,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect pricing tier from Claude model identifier.
|
|
63
|
+
* Examples: 'claude-opus-4-7', 'claude-sonnet-4-5', 'claude-haiku-4-5'.
|
|
64
|
+
*/
|
|
65
|
+
function detectPricingTier(model) {
|
|
66
|
+
if (!model) return 'claude-sonnet';
|
|
67
|
+
const m = model.toLowerCase();
|
|
68
|
+
|
|
69
|
+
if (m.includes('opus')) {
|
|
70
|
+
// Opus 4.5, 4.6, 4.7, and future 5+ use the new reduced pricing.
|
|
71
|
+
if (/opus[-_.]?4[-_.]?[5-9]\b/.test(m)) return 'claude-opus-new';
|
|
72
|
+
if (/opus[-_.]?[5-9]/.test(m)) return 'claude-opus-new';
|
|
73
|
+
// Opus 4, 4.1, 3 → legacy premium pricing.
|
|
74
|
+
return 'claude-opus-legacy';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (m.includes('haiku')) {
|
|
78
|
+
if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
|
|
79
|
+
if (/haiku[-_.]?3[-_.]?5/.test(m)) return 'claude-haiku-3-5';
|
|
80
|
+
if (/haiku[-_.]?3\b/.test(m)) return 'claude-haiku-3';
|
|
81
|
+
return 'claude-haiku-4-5';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sonnet (default fallback): 3.7, 4, 4.5, 4.6 all share the same pricing.
|
|
85
|
+
return 'claude-sonnet';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tokensToMillions(n) {
|
|
89
|
+
return n / 1_000_000;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Estimate costs for given token totals.
|
|
94
|
+
*
|
|
95
|
+
* totals shape (from parser.js):
|
|
96
|
+
* input — non-cached input tokens
|
|
97
|
+
* cacheCreation — total cache-write tokens (5m + 1h combined, as reported by API)
|
|
98
|
+
* cacheRead — cache-hit tokens
|
|
99
|
+
* ephemeral5m — portion of cacheCreation billed at 5m rate (1.25x input)
|
|
100
|
+
* ephemeral1h — portion of cacheCreation billed at 1h rate (2x input)
|
|
101
|
+
* output — output tokens
|
|
102
|
+
*/
|
|
103
|
+
export function estimateCost(totals, model) {
|
|
104
|
+
const tier = detectPricingTier(model);
|
|
105
|
+
const p = PRICING[tier];
|
|
106
|
+
|
|
107
|
+
// Prefer explicit 5m/1h split when available; fall back to cacheCreation at 5m rate
|
|
108
|
+
// (conservative — 5m is cheaper than 1h).
|
|
109
|
+
const write5m = totals.ephemeral5m ?? 0;
|
|
110
|
+
const write1h = totals.ephemeral1h ?? 0;
|
|
111
|
+
const trackedWrites = write5m + write1h;
|
|
112
|
+
const untracked = Math.max(0, (totals.cacheCreation ?? 0) - trackedWrites);
|
|
113
|
+
|
|
114
|
+
const actual =
|
|
115
|
+
tokensToMillions(totals.input) * p.input +
|
|
116
|
+
tokensToMillions(write5m + untracked) * p.cacheWrite5m +
|
|
117
|
+
tokensToMillions(write1h) * p.cacheWrite1h +
|
|
118
|
+
tokensToMillions(totals.cacheRead) * p.cacheRead +
|
|
119
|
+
tokensToMillions(totals.output) * p.output;
|
|
120
|
+
|
|
121
|
+
// What it would cost without any caching (all input billed at base rate).
|
|
122
|
+
const totalInput = totals.input + totals.cacheCreation + totals.cacheRead;
|
|
123
|
+
const noCacheCost =
|
|
124
|
+
tokensToMillions(totalInput) * p.input +
|
|
125
|
+
tokensToMillions(totals.output) * p.output;
|
|
126
|
+
|
|
127
|
+
// What it would cost if all 1h-tier writes had been 5m instead
|
|
128
|
+
// (higher miss rate — estimate 3x more cache re-creation for sessions > 5min).
|
|
129
|
+
const extra5mCreation = write1h * 2; // sessions that would re-create under 5m TTL
|
|
130
|
+
const scenario5mWrites = write5m + write1h + untracked + extra5mCreation;
|
|
131
|
+
const scenario5mCost =
|
|
132
|
+
tokensToMillions(totals.input) * p.input +
|
|
133
|
+
tokensToMillions(scenario5mWrites) * p.cacheWrite5m +
|
|
134
|
+
tokensToMillions(Math.max(0, totals.cacheRead - extra5mCreation)) * p.cacheRead +
|
|
135
|
+
tokensToMillions(totals.output) * p.output;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
tier,
|
|
139
|
+
actual: round(actual),
|
|
140
|
+
noCacheCost: round(noCacheCost),
|
|
141
|
+
savings: round(noCacheCost - actual),
|
|
142
|
+
savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
|
|
143
|
+
scenario5mCost: round(scenario5mCost),
|
|
144
|
+
extraCostIf5m: round(scenario5mCost - actual),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function round(n) {
|
|
149
|
+
return Math.round(n * 100) / 100;
|
|
150
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function formatReport({ trend }) {
|
|
2
|
+
const header = 'date,hit_rate,api_calls,cache_read,cache_creation,ephemeral_5m,ephemeral_1h,input,output';
|
|
3
|
+
const rows = trend.map(
|
|
4
|
+
(d) =>
|
|
5
|
+
`${d.date},${d.hitRate.toFixed(4)},${d.apiCalls},${d.cacheRead},${d.cacheCreation},${d.ephemeral5m},${d.ephemeral1h},${d.input},${d.output}`,
|
|
6
|
+
);
|
|
7
|
+
return [header, ...rows].join('\n');
|
|
8
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline formatter — compact single-line output for Claude Code statusline API.
|
|
3
|
+
* Called every ~300ms, so kept minimal and fast. ANSI color codes included by default.
|
|
4
|
+
*
|
|
5
|
+
* Example output (color):
|
|
6
|
+
* 🧠 97.5% · 1h · ⏱ 42:15 · 💰 $4.8K · 7d
|
|
7
|
+
*
|
|
8
|
+
* Disable color with NO_COLOR=1 env var or --no-color flag.
|
|
9
|
+
* Disable the TTL countdown with --no-timer.
|
|
10
|
+
*
|
|
11
|
+
* Usage in ~/.claude/settings.json:
|
|
12
|
+
* {
|
|
13
|
+
* "statusLine": {
|
|
14
|
+
* "type": "command",
|
|
15
|
+
* "command": "npx claude-token-saver --statusline"
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const RESET = '\x1b[0m';
|
|
21
|
+
const RED = '\x1b[31m';
|
|
22
|
+
const GREEN = '\x1b[32m';
|
|
23
|
+
const YELLOW = '\x1b[33m';
|
|
24
|
+
const CYAN = '\x1b[36m';
|
|
25
|
+
const GRAY = '\x1b[90m';
|
|
26
|
+
const BOLD = '\x1b[1m';
|
|
27
|
+
|
|
28
|
+
function formatMoney(usd) {
|
|
29
|
+
if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}K`;
|
|
30
|
+
if (usd >= 100) return `$${usd.toFixed(0)}`;
|
|
31
|
+
if (usd >= 10) return `$${usd.toFixed(1)}`;
|
|
32
|
+
return `$${usd.toFixed(2)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatPct(v) {
|
|
36
|
+
return `${(v * 100).toFixed(1)}%`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format a remaining-seconds countdown as MM:SS (or H:MM when ≥ 1h).
|
|
41
|
+
*/
|
|
42
|
+
function formatTimer(remainingSec) {
|
|
43
|
+
if (remainingSec <= 0) return 'EXPIRED';
|
|
44
|
+
const totalSec = Math.floor(remainingSec);
|
|
45
|
+
const h = Math.floor(totalSec / 3600);
|
|
46
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
47
|
+
const s = totalSec % 60;
|
|
48
|
+
if (h > 0) return `${h}:${String(m).padStart(2, '0')}`;
|
|
49
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
54
|
+
* @param {object} [opts]
|
|
55
|
+
* @param {boolean} [opts.color=true] - emit ANSI escape codes
|
|
56
|
+
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
57
|
+
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
58
|
+
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
59
|
+
*/
|
|
60
|
+
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
|
|
61
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
|
|
62
|
+
const { hitRate } = summary;
|
|
63
|
+
|
|
64
|
+
// Hit rate → color signal
|
|
65
|
+
const hitColor =
|
|
66
|
+
hitRate >= 0.85 ? GREEN :
|
|
67
|
+
hitRate >= 0.70 ? YELLOW :
|
|
68
|
+
RED;
|
|
69
|
+
|
|
70
|
+
// TTL dominance → color signal (1h = good, 5m = warning).
|
|
71
|
+
// The subscription plan fixes this, so the bucket rarely changes — it's the countdown that matters.
|
|
72
|
+
const is1h = ttl.pct1h >= 0.5;
|
|
73
|
+
const bucketLabel = is1h ? '1h' : '5m';
|
|
74
|
+
const bucketColor = is1h ? GREEN : YELLOW;
|
|
75
|
+
const ttlSeconds = is1h ? 3600 : 300;
|
|
76
|
+
|
|
77
|
+
const savings = cost?.savings ?? 0;
|
|
78
|
+
|
|
79
|
+
const c = (v) => (color ? v : '');
|
|
80
|
+
const isIcon = mode === 'icon';
|
|
81
|
+
|
|
82
|
+
// Labels per mode.
|
|
83
|
+
// text: "Cache hit 98.3%" | verbose: "Cache hit 98.3%"
|
|
84
|
+
// icon: "🧠 98.3%" | verbose: "🧠 Cache hit 98.3%"
|
|
85
|
+
const hitLabel = isIcon
|
|
86
|
+
? (verbose ? '🧠 Cache hit' : '🧠')
|
|
87
|
+
: 'Cache hit';
|
|
88
|
+
const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
89
|
+
|
|
90
|
+
// text: "Cost saved $1.5K" | same in verbose
|
|
91
|
+
// icon: "💰 $1.5K" | verbose: "💰 Cost saved $1.5K"
|
|
92
|
+
const saveLabel = isIcon
|
|
93
|
+
? (verbose ? '💰 Cost saved' : '💰')
|
|
94
|
+
: 'Cost saved';
|
|
95
|
+
const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
96
|
+
|
|
97
|
+
const periodSeg = verbose
|
|
98
|
+
? `${c(GRAY)}last ${options.days}d${c(RESET)}`
|
|
99
|
+
: `${c(GRAY)}${options.days}d${c(RESET)}`;
|
|
100
|
+
|
|
101
|
+
// TTL countdown — how much time is left on the last API call's cache entry.
|
|
102
|
+
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
103
|
+
// TTL window, and the next call (hit) within that window resets it. So the
|
|
104
|
+
// countdown visibly ticks down between prompts, and "resets" happens as a
|
|
105
|
+
// jump back toward the bucket max the moment you send another message.
|
|
106
|
+
// text compact: "Expires 1h 59:58"
|
|
107
|
+
// text verbose: "1h bucket · expires in 59:58"
|
|
108
|
+
// icon compact: "⏳ 1h 59:58"
|
|
109
|
+
// icon verbose: "⏳ Expires 1h 59:58"
|
|
110
|
+
let ttlSeg;
|
|
111
|
+
if (timer && lastActivity) {
|
|
112
|
+
const elapsed = (Date.now() - lastActivity) / 1000;
|
|
113
|
+
const remaining = ttlSeconds - elapsed;
|
|
114
|
+
const text = formatTimer(remaining);
|
|
115
|
+
const pct = remaining / ttlSeconds;
|
|
116
|
+
const timerColor =
|
|
117
|
+
remaining <= 0 ? RED :
|
|
118
|
+
pct > 0.30 ? GREEN :
|
|
119
|
+
pct > 0.10 ? YELLOW :
|
|
120
|
+
RED;
|
|
121
|
+
|
|
122
|
+
if (isIcon) {
|
|
123
|
+
const prefix = verbose ? '⏳ Expires ' : '⏳ ';
|
|
124
|
+
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)} ${c(timerColor)}${text}${c(RESET)}`;
|
|
125
|
+
} else if (verbose) {
|
|
126
|
+
ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
127
|
+
} else {
|
|
128
|
+
ttlSeg = `${c(bucketColor)}Expires ${bucketLabel}${c(RESET)} ${c(timerColor)}${text}${c(RESET)}`;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
if (isIcon) {
|
|
132
|
+
const prefix = verbose ? '⏳ Bucket ' : '⏳ ';
|
|
133
|
+
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
134
|
+
} else if (verbose) {
|
|
135
|
+
ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)}`;
|
|
136
|
+
} else {
|
|
137
|
+
ttlSeg = `${c(bucketColor)}Bucket ${bucketLabel}${c(RESET)}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Context window chip (e.g. "📦 1M" or "📦 200k"). 1M gets a warning color
|
|
142
|
+
// because it's the expensive default on Max plans after Opus 4.7.
|
|
143
|
+
let ctxSeg = null;
|
|
144
|
+
if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
145
|
+
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
146
|
+
const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
|
|
147
|
+
if (isIcon) {
|
|
148
|
+
ctxSeg = `${c(ctxColor)}📦 ${label}${c(RESET)}`;
|
|
149
|
+
} else if (verbose) {
|
|
150
|
+
ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
|
|
151
|
+
} else {
|
|
152
|
+
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Spike chip — one word only, keeps the statusline single-line.
|
|
157
|
+
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
158
|
+
|
|
159
|
+
const segs = [hitSeg, ttlSeg, saveSeg];
|
|
160
|
+
if (ctxSeg) segs.push(ctxSeg);
|
|
161
|
+
if (spikeSeg) segs.push(spikeSeg);
|
|
162
|
+
segs.push(periodSeg);
|
|
163
|
+
return segs.join(' · ');
|
|
164
|
+
}
|