claude-token-saver 2.1.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 +50 -23
- package/bin/cli.js +285 -3
- package/package.json +1 -1
- package/src/advice.js +120 -0
- package/src/caps-cache.js +84 -0
- package/src/demo.js +73 -1
- package/src/format-time.js +44 -0
- package/src/formatters/statusline.js +214 -14
- package/src/formatters/table.js +36 -2
- package/src/handoff.js +161 -0
- package/src/history.js +208 -8
- package/src/installer.js +55 -30
- package/src/window-labels.js +61 -0
- package/examples/statusline-with-rz1989s.sh +0 -52
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*.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
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
|
+
*
|
|
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
|
+
/**
|
|
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;
|
|
34
|
+
try {
|
|
35
|
+
ensureDir();
|
|
36
|
+
const payload = {
|
|
37
|
+
capturedAt: Date.now(),
|
|
38
|
+
caps: snapshot.caps || null,
|
|
39
|
+
model: snapshot.model || null,
|
|
40
|
+
};
|
|
41
|
+
writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
|
|
42
|
+
} catch {
|
|
43
|
+
// best-effort cache, never blocks the statusline
|
|
44
|
+
}
|
|
45
|
+
}
|
|
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
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {number} [opts.maxAgeMs=5*60*1000] - drop snapshots older than this.
|
|
68
|
+
* @returns {{ caps: object|null, model: string|null }|null}
|
|
69
|
+
*/
|
|
70
|
+
export function loadRecentSnapshot({ maxAgeMs = 5 * 60 * 1000 } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
73
|
+
const raw = readFileSync(CACHE_PATH, 'utf8');
|
|
74
|
+
const data = JSON.parse(raw);
|
|
75
|
+
if (!data || typeof data.capturedAt !== 'number') return null;
|
|
76
|
+
if (Date.now() - data.capturedAt > maxAgeMs) return null;
|
|
77
|
+
return {
|
|
78
|
+
caps: normalizeCaps(data.caps),
|
|
79
|
+
model: data.model || null,
|
|
80
|
+
};
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/demo.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* it flows through formatReport() unchanged.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
// Default cap usage attached to every scenario so the always-on ✦ / 📅
|
|
14
|
+
// gauge segments render in the demo line. Individual scenarios override `caps`
|
|
15
|
+
// when they're meant to surface a `🚨 5H/7D` cap-warn chip.
|
|
16
|
+
const HEALTHY_CAPS = { fiveHour: 31, sevenDay: 10 };
|
|
17
|
+
const DEFAULT_MODEL = 'Opus 4.7';
|
|
18
|
+
|
|
13
19
|
const SCENARIOS = [
|
|
14
20
|
{
|
|
15
21
|
name: 'healthy',
|
|
@@ -21,6 +27,7 @@ const SCENARIOS = [
|
|
|
21
27
|
elapsedSec: 30,
|
|
22
28
|
contextSize: '200k',
|
|
23
29
|
spikeChip: null,
|
|
30
|
+
caps: HEALTHY_CAPS,
|
|
24
31
|
},
|
|
25
32
|
},
|
|
26
33
|
{
|
|
@@ -33,6 +40,7 @@ const SCENARIOS = [
|
|
|
33
40
|
elapsedSec: 30,
|
|
34
41
|
contextSize: '200k',
|
|
35
42
|
spikeChip: '⚠ Cache miss',
|
|
43
|
+
caps: HEALTHY_CAPS,
|
|
36
44
|
},
|
|
37
45
|
},
|
|
38
46
|
{
|
|
@@ -45,6 +53,7 @@ const SCENARIOS = [
|
|
|
45
53
|
elapsedSec: 3000, // ~10min remaining of 1h
|
|
46
54
|
contextSize: '200k',
|
|
47
55
|
spikeChip: null,
|
|
56
|
+
caps: HEALTHY_CAPS,
|
|
48
57
|
},
|
|
49
58
|
},
|
|
50
59
|
{
|
|
@@ -57,6 +66,7 @@ const SCENARIOS = [
|
|
|
57
66
|
elapsedSec: 3360, // ~4min remaining of 1h
|
|
58
67
|
contextSize: '200k',
|
|
59
68
|
spikeChip: null,
|
|
69
|
+
caps: HEALTHY_CAPS,
|
|
60
70
|
},
|
|
61
71
|
},
|
|
62
72
|
{
|
|
@@ -69,6 +79,7 @@ const SCENARIOS = [
|
|
|
69
79
|
elapsedSec: 4000, // past 1h
|
|
70
80
|
contextSize: '200k',
|
|
71
81
|
spikeChip: null,
|
|
82
|
+
caps: HEALTHY_CAPS,
|
|
72
83
|
},
|
|
73
84
|
},
|
|
74
85
|
{
|
|
@@ -82,6 +93,7 @@ const SCENARIOS = [
|
|
|
82
93
|
elapsedSec: 60,
|
|
83
94
|
contextSize: '200k',
|
|
84
95
|
spikeChip: '⚠ 5m TTL',
|
|
96
|
+
caps: HEALTHY_CAPS,
|
|
85
97
|
},
|
|
86
98
|
},
|
|
87
99
|
{
|
|
@@ -94,6 +106,7 @@ const SCENARIOS = [
|
|
|
94
106
|
elapsedSec: 30,
|
|
95
107
|
contextSize: '1M',
|
|
96
108
|
spikeChip: '⚠ 1M ON',
|
|
109
|
+
caps: HEALTHY_CAPS,
|
|
97
110
|
},
|
|
98
111
|
},
|
|
99
112
|
{
|
|
@@ -106,6 +119,7 @@ const SCENARIOS = [
|
|
|
106
119
|
elapsedSec: 30,
|
|
107
120
|
contextSize: '200k',
|
|
108
121
|
spikeChip: '⚠ Input spike',
|
|
122
|
+
caps: HEALTHY_CAPS,
|
|
109
123
|
},
|
|
110
124
|
},
|
|
111
125
|
{
|
|
@@ -118,6 +132,7 @@ const SCENARIOS = [
|
|
|
118
132
|
elapsedSec: 30,
|
|
119
133
|
contextSize: '200k',
|
|
120
134
|
spikeChip: '⚠ Rebuild churn',
|
|
135
|
+
caps: HEALTHY_CAPS,
|
|
121
136
|
},
|
|
122
137
|
},
|
|
123
138
|
{
|
|
@@ -130,6 +145,7 @@ const SCENARIOS = [
|
|
|
130
145
|
elapsedSec: 30,
|
|
131
146
|
contextSize: '200k',
|
|
132
147
|
spikeChip: '⚠ Output heavy',
|
|
148
|
+
caps: HEALTHY_CAPS,
|
|
133
149
|
},
|
|
134
150
|
},
|
|
135
151
|
{
|
|
@@ -142,6 +158,46 @@ const SCENARIOS = [
|
|
|
142
158
|
elapsedSec: 30,
|
|
143
159
|
contextSize: '200k',
|
|
144
160
|
spikeChip: '⚠ Call surge',
|
|
161
|
+
caps: HEALTHY_CAPS,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: '5h-cap-warn',
|
|
166
|
+
label: '🚨 5H cap 94% (imminent block)',
|
|
167
|
+
data: {
|
|
168
|
+
hitRate: 0.93,
|
|
169
|
+
pct1h: 0.95,
|
|
170
|
+
savings: 1840,
|
|
171
|
+
elapsedSec: 30,
|
|
172
|
+
contextSize: '200k',
|
|
173
|
+
spikeChip: null,
|
|
174
|
+
caps: { fiveHour: 94, sevenDay: 12 },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: '7d-cap-warn',
|
|
179
|
+
label: '🚨 7D cap 92% (week pacing)',
|
|
180
|
+
data: {
|
|
181
|
+
hitRate: 0.91,
|
|
182
|
+
pct1h: 0.92,
|
|
183
|
+
savings: 4200,
|
|
184
|
+
elapsedSec: 30,
|
|
185
|
+
contextSize: '200k',
|
|
186
|
+
spikeChip: null,
|
|
187
|
+
caps: { fiveHour: 35, sevenDay: 92 },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'caps-yellow',
|
|
192
|
+
label: '⚠ 5H 78% / 7D 70% (heads-up)',
|
|
193
|
+
data: {
|
|
194
|
+
hitRate: 0.95,
|
|
195
|
+
pct1h: 0.95,
|
|
196
|
+
savings: 2680,
|
|
197
|
+
elapsedSec: 30,
|
|
198
|
+
contextSize: '200k',
|
|
199
|
+
spikeChip: null,
|
|
200
|
+
caps: { fiveHour: 78, sevenDay: 70 },
|
|
145
201
|
},
|
|
146
202
|
},
|
|
147
203
|
];
|
|
@@ -220,6 +276,20 @@ export function buildTableDemoData(options = {}) {
|
|
|
220
276
|
}
|
|
221
277
|
|
|
222
278
|
|
|
279
|
+
// Build the rate-limit caps shape that statusline.js consumes. Reset times
|
|
280
|
+
// are anchored to "wall-clock-ish" offsets so the cycle reads stable: 5H
|
|
281
|
+
// resets ~2h out, 7D resets a few days out — matches what real /usage shows.
|
|
282
|
+
function buildCapsShape(caps) {
|
|
283
|
+
if (!caps) return null;
|
|
284
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
285
|
+
return {
|
|
286
|
+
windows: [
|
|
287
|
+
{ key: 'five_hour', usedPct: caps.fiveHour, resetsAt: nowSec + 60 * 130 }, // ~2h10m
|
|
288
|
+
{ key: 'seven_day', usedPct: caps.sevenDay, resetsAt: nowSec + 60 * 60 * 76 }, // ~3d4h
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
223
293
|
export function buildScenarioData(scenarioName, options) {
|
|
224
294
|
let scenario;
|
|
225
295
|
if (scenarioName === 'cycle') {
|
|
@@ -231,7 +301,7 @@ export function buildScenarioData(scenarioName, options) {
|
|
|
231
301
|
if (!scenario) return null;
|
|
232
302
|
}
|
|
233
303
|
|
|
234
|
-
const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, spikeChip } = scenario.data;
|
|
304
|
+
const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, spikeChip, caps } = scenario.data;
|
|
235
305
|
return {
|
|
236
306
|
summary: { hitRate },
|
|
237
307
|
ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
|
|
@@ -245,6 +315,8 @@ export function buildScenarioData(scenarioName, options) {
|
|
|
245
315
|
lastActivity: Date.now() - elapsedSec * 1000,
|
|
246
316
|
contextWindow: { size: contextSize },
|
|
247
317
|
spikeChip,
|
|
318
|
+
caps: buildCapsShape(caps),
|
|
319
|
+
model: DEFAULT_MODEL,
|
|
248
320
|
_demoLabel: scenario.label,
|
|
249
321
|
_demoName: scenario.name,
|
|
250
322
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "resets in" countdown formatter. The 7-day window can be ~5 days
|
|
3
|
+
* away, so we promote whole-day spans into a `Xd Yh` shape; otherwise it's
|
|
4
|
+
* `Xh Ym` for ≥1h and `Xm` under that.
|
|
5
|
+
*
|
|
6
|
+
* @param {number} resetsAt - Unix-epoch seconds when the window resets.
|
|
7
|
+
* @param {Date} [now=new Date()]
|
|
8
|
+
* @returns {string|null} formatted countdown, or null when the input isn't usable.
|
|
9
|
+
*/
|
|
10
|
+
export function formatResetIn(resetsAt, now = new Date()) {
|
|
11
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
12
|
+
const remainingSec = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
|
|
13
|
+
if (remainingSec <= 0) return '0m';
|
|
14
|
+
const d = Math.floor(remainingSec / 86400);
|
|
15
|
+
const h = Math.floor((remainingSec % 86400) / 3600);
|
|
16
|
+
const m = Math.floor((remainingSec % 3600) / 60);
|
|
17
|
+
if (d > 0) return `${d}d ${h}h`;
|
|
18
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
19
|
+
return `${m}m`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wall-clock time at which the window resets, in the user's local timezone.
|
|
24
|
+
* Same-day resets show `HH:MM`; resets that cross midnight prepend the weekday
|
|
25
|
+
* (e.g. `Sat 21:30`) so a glance at the statusline doesn't mislead.
|
|
26
|
+
*
|
|
27
|
+
* @param {number} resetsAt - Unix-epoch seconds.
|
|
28
|
+
* @param {Date} [now=new Date()]
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
export function formatResetClock(resetsAt, now = new Date()) {
|
|
32
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
33
|
+
const reset = new Date(resetsAt * 1000);
|
|
34
|
+
if (Number.isNaN(reset.getTime())) return null;
|
|
35
|
+
const hh = String(reset.getHours()).padStart(2, '0');
|
|
36
|
+
const mm = String(reset.getMinutes()).padStart(2, '0');
|
|
37
|
+
const sameDay =
|
|
38
|
+
reset.getFullYear() === now.getFullYear() &&
|
|
39
|
+
reset.getMonth() === now.getMonth() &&
|
|
40
|
+
reset.getDate() === now.getDate();
|
|
41
|
+
if (sameDay) return `${hh}:${mm}`;
|
|
42
|
+
const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][reset.getDay()];
|
|
43
|
+
return `${dow} ${hh}:${mm}`;
|
|
44
|
+
}
|