claude-token-saver 2.7.3 β 2.8.1
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.en.md +2 -2
- package/README.md +2 -2
- package/bin/cli.js +30 -1
- package/package.json +1 -1
- package/src/formatters/statusline.js +23 -6
- package/src/installer.js +58 -1
package/README.en.md
CHANGED
|
@@ -53,12 +53,12 @@ Risk chips lead when something's wrong: `π¨ 5H 94%`, `β 1M ON`, `β Cache m
|
|
|
53
53
|
"statusLine": {
|
|
54
54
|
"type": "command",
|
|
55
55
|
"command": "claude-token-saver --statusline --icon",
|
|
56
|
-
"refreshInterval":
|
|
56
|
+
"refreshInterval": 5
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
`refreshInterval:
|
|
61
|
+
`refreshInterval: 5` keeps the TTL countdown ticking while idle (Claude Code's statusline is otherwise event-driven). 1s also works, but 5s is the recommended default to avoid constant I/O. For Windows PowerShell, see `examples/statusline-command.ps1`.
|
|
62
62
|
|
|
63
63
|
## Commands
|
|
64
64
|
|
package/README.md
CHANGED
|
@@ -53,12 +53,12 @@ TTL Breakdown / Cost Impact / Daily Trend ...
|
|
|
53
53
|
"statusLine": {
|
|
54
54
|
"type": "command",
|
|
55
55
|
"command": "claude-token-saver --statusline --icon",
|
|
56
|
-
"refreshInterval":
|
|
56
|
+
"refreshInterval": 5
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
`refreshInterval:
|
|
61
|
+
`refreshInterval: 5`λ TTL μΉ΄μ΄νΈλ€μ΄μ΄ idle μνμμλ 5μ΄λ§λ€ κ°±μ λκ² ν©λλ€ (1μ΄λ κ°λ₯νμ§λ§ μμ I/O λΆλ΄μ νΌνλ €κ³ 5μ΄λ₯Ό κΈ°λ³Έκ°μΌλ‘ κΆμ₯). Windows(PowerShell)λ `examples/statusline-command.ps1` μ°Έκ³ .
|
|
62
62
|
|
|
63
63
|
## μ£Όμ λͺ
λ Ή
|
|
64
64
|
|
package/bin/cli.js
CHANGED
|
@@ -77,10 +77,31 @@ function extractCaps(stdinJson) {
|
|
|
77
77
|
* `model.display_name` is the contract; fall back to `model.id` when it's
|
|
78
78
|
* absent. Returns null when nothing usable is in the JSON.
|
|
79
79
|
*/
|
|
80
|
+
// Bedrock/litellm proxies pass model IDs like
|
|
81
|
+
// global.anthropic.claude-opus-4-7-20251001-v1:0
|
|
82
|
+
// anthropic.claude-sonnet-4-6-20250930-v1:0
|
|
83
|
+
// bedrock/anthropic.claude-haiku-4-5
|
|
84
|
+
// while Claude Code's `display_name` collapses these to a generic family
|
|
85
|
+
// label ("Opus 4", "Sonnet 4") that hides the actual minor version. Pull the
|
|
86
|
+
// version out of the id when we can spot it so the statusline shows the real
|
|
87
|
+
// model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
|
|
88
|
+
function bedrockDisplayFromId(id) {
|
|
89
|
+
if (typeof id !== 'string') return null;
|
|
90
|
+
const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
|
|
93
|
+
return `${family} ${m[2]}.${m[3]}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
function extractModel(stdinJson) {
|
|
81
97
|
if (!stdinJson || !stdinJson.model) return null;
|
|
82
98
|
const m = stdinJson.model;
|
|
83
|
-
if (typeof m === 'string') return m;
|
|
99
|
+
if (typeof m === 'string') return bedrockDisplayFromId(m) || m;
|
|
100
|
+
// Prefer the id when it carries a precise version (e.g. Bedrock IDs); fall
|
|
101
|
+
// back to display_name for the standard Claude Code path where display_name
|
|
102
|
+
// already says "Claude Opus 4.7".
|
|
103
|
+
const idDerived = bedrockDisplayFromId(m.id);
|
|
104
|
+
if (idDerived) return idDerived;
|
|
84
105
|
if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
|
|
85
106
|
if (typeof m.id === 'string' && m.id) return m.id;
|
|
86
107
|
return null;
|
|
@@ -344,6 +365,14 @@ async function main() {
|
|
|
344
365
|
};
|
|
345
366
|
const r = installAll({ force });
|
|
346
367
|
print('skill', r.skill);
|
|
368
|
+
{
|
|
369
|
+
const s = r.statusline;
|
|
370
|
+
const verb = s.action === 'exists' ? 'already configured (refreshInterval=5)'
|
|
371
|
+
: s.action === 'skipped' ? `skipped β ${s.reason}`
|
|
372
|
+
: s.reason ? `${s.action} β ${s.reason}`
|
|
373
|
+
: s.action;
|
|
374
|
+
console.log(` statusline: ${s.path} (${verb})`);
|
|
375
|
+
}
|
|
347
376
|
if (r.legacy.action === 'removed') {
|
|
348
377
|
print('legacy /token-monitor', r.legacy);
|
|
349
378
|
console.log(' (consolidated into the skill β same workflow, triggered by intent)');
|
package/package.json
CHANGED
|
@@ -100,11 +100,18 @@ function gaugeBar(pct) {
|
|
|
100
100
|
* Format a remaining-seconds countdown as MM:SS (or H:MM when β₯ 1h).
|
|
101
101
|
*/
|
|
102
102
|
function formatTimer(remainingSec) {
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// Defensive: non-finite/NaN inputs (e.g. clock skew, stringified Date) used
|
|
104
|
+
// to slip through and render as "NaN:NaN" or stretched seconds. Treat any
|
|
105
|
+
// weird input as expired rather than rendering garbage in the statusline.
|
|
106
|
+
if (!Number.isFinite(remainingSec) || remainingSec <= 0) return 'EXPIRED';
|
|
107
|
+
const totalSec = Math.max(0, Math.floor(remainingSec));
|
|
105
108
|
const h = Math.floor(totalSec / 3600);
|
|
106
|
-
const
|
|
107
|
-
const
|
|
109
|
+
const mRaw = Math.floor((totalSec % 3600) / 60);
|
|
110
|
+
const sRaw = totalSec % 60;
|
|
111
|
+
// Clamp explicitly so a future regression in the math (or padStart no-op
|
|
112
|
+
// truncation) can never produce m:sss like "4:547".
|
|
113
|
+
const m = Math.min(59, Math.max(0, mRaw));
|
|
114
|
+
const s = Math.min(59, Math.max(0, sRaw));
|
|
108
115
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}`;
|
|
109
116
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
110
117
|
}
|
|
@@ -195,8 +202,18 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
195
202
|
// icon verbose: "β³ Expires 1h 59:58"
|
|
196
203
|
let ttlSeg;
|
|
197
204
|
if (timer && lastActivity) {
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
// Coerce to a numeric ms timestamp. Some upstream paths handed in a Date,
|
|
206
|
+
// a stringified ISO timestamp, or epoch-seconds β any of which silently
|
|
207
|
+
// produces NaN/huge values when subtracted from Date.now(), which then
|
|
208
|
+
// bypasses formatTimer's normal MM:SS shape.
|
|
209
|
+
const laMs =
|
|
210
|
+
typeof lastActivity === 'number'
|
|
211
|
+
? (lastActivity < 1e12 ? lastActivity * 1000 : lastActivity) // seconds β ms
|
|
212
|
+
: (lastActivity instanceof Date ? lastActivity.getTime() : Date.parse(lastActivity));
|
|
213
|
+
const elapsed = Number.isFinite(laMs) ? (Date.now() - laMs) / 1000 : Infinity;
|
|
214
|
+
// Clamp remaining into the bucket so a clock-skew or stale-state edge case
|
|
215
|
+
// can't display a value larger than the bucket itself.
|
|
216
|
+
const remaining = Math.min(ttlSeconds, ttlSeconds - elapsed);
|
|
200
217
|
const text = formatTimer(remaining);
|
|
201
218
|
const pct = remaining / ttlSeconds;
|
|
202
219
|
const timerColor =
|
package/src/installer.js
CHANGED
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
* exist on every platform.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { claudeUserDir } from './paths.js';
|
|
19
19
|
|
|
20
|
+
const STATUSLINE_COMMAND = 'claude-token-saver --statusline --icon';
|
|
21
|
+
const STATUSLINE_REFRESH_INTERVAL = 5;
|
|
22
|
+
|
|
20
23
|
const SKILL_BODY = `---
|
|
21
24
|
name: claude-token-saver
|
|
22
25
|
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\`.
|
|
@@ -127,9 +130,63 @@ export function removeLegacyCommand() {
|
|
|
127
130
|
return { path: file, action: 'removed' };
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
// Registers/repairs the Claude Code statusLine entry in ~/.claude/settings.json.
|
|
134
|
+
// - No statusLine yet: insert ours with refreshInterval:1.
|
|
135
|
+
// - statusLine already points at claude-token-saver: ensure refreshInterval:1
|
|
136
|
+
// (this is the bit that makes the TTL countdown tick every second while idle).
|
|
137
|
+
// - statusLine points at a different command: leave it alone unless --force.
|
|
138
|
+
export function installStatusline({ force = false } = {}) {
|
|
139
|
+
const dir = claudeUserDir();
|
|
140
|
+
const file = join(dir, 'settings.json');
|
|
141
|
+
mkdirSync(dir, { recursive: true });
|
|
142
|
+
|
|
143
|
+
let settings = {};
|
|
144
|
+
if (existsSync(file)) {
|
|
145
|
+
try {
|
|
146
|
+
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cur = settings.statusLine;
|
|
153
|
+
const targetsUs = cur && typeof cur.command === 'string' && cur.command.includes('claude-token-saver');
|
|
154
|
+
|
|
155
|
+
if (!cur) {
|
|
156
|
+
settings.statusLine = {
|
|
157
|
+
type: 'command',
|
|
158
|
+
command: STATUSLINE_COMMAND,
|
|
159
|
+
refreshInterval: STATUSLINE_REFRESH_INTERVAL,
|
|
160
|
+
};
|
|
161
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
162
|
+
return { path: file, action: 'created' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (targetsUs) {
|
|
166
|
+
if (cur.refreshInterval === STATUSLINE_REFRESH_INTERVAL) {
|
|
167
|
+
return { path: file, action: 'exists' };
|
|
168
|
+
}
|
|
169
|
+
cur.refreshInterval = STATUSLINE_REFRESH_INTERVAL;
|
|
170
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
171
|
+
return { path: file, action: 'updated', reason: 'set refreshInterval=1' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!force) {
|
|
175
|
+
return { path: file, action: 'skipped', reason: `existing statusLine command (${cur.command}) β re-run with --force to overwrite` };
|
|
176
|
+
}
|
|
177
|
+
settings.statusLine = {
|
|
178
|
+
type: 'command',
|
|
179
|
+
command: STATUSLINE_COMMAND,
|
|
180
|
+
refreshInterval: STATUSLINE_REFRESH_INTERVAL,
|
|
181
|
+
};
|
|
182
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
183
|
+
return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
|
|
184
|
+
}
|
|
185
|
+
|
|
130
186
|
export function installAll({ force = false } = {}) {
|
|
131
187
|
return {
|
|
132
188
|
skill: installSkill({ force }),
|
|
189
|
+
statusline: installStatusline({ force }),
|
|
133
190
|
legacy: removeLegacyCommand(),
|
|
134
191
|
};
|
|
135
192
|
}
|