claude-token-saver 2.0.3 → 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/src/advice.js CHANGED
@@ -28,94 +28,99 @@ function toggleShortcut() {
28
28
 
29
29
  export const ISSUE_MESSAGES = {
30
30
  LARGE_INPUT_PER_REQUEST: {
31
- title: '요청당 입력 토큰이 평소보다 매우 큽니다 (1M 컨텍스트 의심)',
31
+ title: 'Per-request input tokens are unusually large (1M context suspected)',
32
32
  explain:
33
- 'Opus 4.7부터 1M 컨텍스트가 표준 가격으로 풀리면서 Max 플랜은 자동으로 1M로 승격됩니다. ' +
34
- ' 컨텍스트가 200k 넘으면 long-context 단가가 적용되고 캐시 재사용도 어려워집니다.',
33
+ 'Since Opus 4.7, 1M context is priced at the standard rate, and Max plans auto-promote ' +
34
+ 'sessions to 1M. Once context goes past 200k, long-context pricing kicks in and cache reuse drops.',
35
35
  actions: () => [
36
36
  {
37
- label: '1M 컨텍스트 OFF (환경변수)',
37
+ label: 'Disable 1M context (env var)',
38
38
  commands: disable1mEnvSnippet(),
39
39
  },
40
40
  {
41
- label: '세션 내 토글',
42
- commands: [`단축키 ${toggleShortcut()} 즉시 On/Off`],
41
+ label: 'In-session toggle',
42
+ commands: [`Press ${toggleShortcut()} to toggle on/off instantly`],
43
43
  },
44
44
  {
45
- label: '⚠ 알려진 버그 #31640',
45
+ label: '⚠ Known bug #31640',
46
46
  commands: [
47
- '/model 200k 선택해도 컨텍스트가 1M에 머무는 케이스가 있습니다.',
48
- '확실히 끄려면 환경변수를 설정한 Claude Code를 재시작하세요.',
47
+ '/model 200k selection sometimes does not stick — context stays at 1M.',
48
+ 'To force off: set the env var above and restart Claude Code.',
49
49
  ],
50
50
  },
51
51
  ],
52
52
  },
53
53
  LOW_HIT_RATE: {
54
- title: '캐시 히트율이 낮습니다',
54
+ title: 'Cache hit rate is low',
55
55
  explain:
56
- '캐시 히트율이 떨어지면 같은 프롬프트 prefix 매번 다시 작성하게 되어 입력 비용이 커집니다.',
56
+ 'A low hit rate means the same prompt prefix is being rewritten on every call, ' +
57
+ 'inflating input cost.',
57
58
  actions: () => [
58
59
  {
59
- label: '세션을 너무 자주 새로 열지 않았는지 확인',
60
- commands: [' 작업은 같은 세션에서 이어가세요 (컨텍스트 전환 = 캐시 미스)'],
60
+ label: 'Avoid opening fresh sessions too often',
61
+ commands: ['Continue the same task in the same session (context switching = cache miss)'],
61
62
  },
62
63
  {
63
- label: '프롬프트 prefix 안정화',
64
- commands: ['시스템 프롬프트·도구 정의가 요청마다 바뀌면 캐시가 매번 무효화됩니다'],
64
+ label: 'Stabilize the prompt prefix',
65
+ commands: ['System prompts / tool definitions that change per request invalidate the cache every time'],
65
66
  },
66
67
  ],
67
68
  },
68
69
  BUCKET_5M_DOMINANT: {
69
- title: '5 TTL 쓰기가 대부분입니다',
70
+ title: 'Most cache writes are landing in the 5-minute TTL bucket',
70
71
  explain:
71
- 'Pro 플랜은 5분 TTL 고정됩니다. 5 이상 간격이 벌어지면 캐시가 만료되어 재작성 비용이 발생합니다.',
72
+ 'Pro plan is locked to 5m TTL. Gaps longer than 5 minutes expire the cache and force ' +
73
+ 'a costly rebuild.',
72
74
  actions: () => [
73
75
  {
74
- label: '5 규칙',
76
+ label: 'The 5-minute rule',
75
77
  commands: [
76
- '5 안에 아무 프롬프트라도 보내면 prefix 캐시가 유지됩니다',
77
- ' 작업이 필요하면 Max 플랜으로 1h TTL 자동 적용',
78
+ 'Sending any prompt within 5 minutes keeps the prefix cache warm',
79
+ 'Upgrade to Max for the 1h TTL bucket on long tasks',
78
80
  ],
79
81
  },
80
82
  ],
81
83
  },
82
84
  HIGH_OUTPUT_RATIO: {
83
- title: 'Output 비중이 비정상적으로 높습니다',
85
+ title: 'Output share is abnormally high',
84
86
  explain:
85
- 'Output 토큰은 입력보다 5배 이상 비쌉니다. 에이전트가 장문을 반복 생성하지 않는지 확인하세요.',
87
+ 'Output tokens are 5x+ pricier than input. Check whether the agent is regenerating ' +
88
+ 'long content unnecessarily.',
86
89
  actions: () => [
87
90
  {
88
- label: '출력 길이 제한',
91
+ label: 'Cap output length',
89
92
  commands: [
90
- '불필요한 전체 파일 쓰기·재생성 지양 (Edit 툴 활용)',
91
- ' 문서·README 생성 요청을 스크립트화해서 줄이세요',
93
+ 'Avoid full-file rewrites prefer the Edit tool',
94
+ 'Move long doc/README generation requests into scripts to shrink output',
92
95
  ],
93
96
  },
94
97
  ],
95
98
  },
96
99
  HIGH_REQUEST_COUNT: {
97
- title: '세션의 API 호출 수가 평소의 3배 이상입니다',
100
+ title: 'API calls in this session are 3x+ the baseline',
98
101
  explain:
99
- ' 호출이 과도하거나 루프/재시도가 많으면 호출당 prefix 재전송으로 입력 비용이 폭증합니다.',
102
+ 'Excessive tool calls or retry/loop patterns rebroadcast the prefix on every call, ' +
103
+ 'spiking input cost.',
100
104
  actions: () => [
101
105
  {
102
- label: '병렬/일괄 처리',
103
- commands: ['독립적인 조사는 메시지에 여러 도구 호출로 묶으세요'],
106
+ label: 'Parallel / batch processing',
107
+ commands: ['Bundle independent investigations into one message with multiple tool calls'],
104
108
  },
105
109
  {
106
- label: '루프 감지',
107
- commands: ['같은 테스트·검색을 반복하는 에이전트 루프가 없는지 확인'],
110
+ label: 'Watch for loops',
111
+ commands: ['Check that the agent is not repeating the same test/search in a loop'],
108
112
  },
109
113
  ],
110
114
  },
111
115
  FREQUENT_CACHE_REBUILD: {
112
- title: '캐시 쓰기가 읽기보다 많습니다',
116
+ title: 'Cache writes outweigh cache reads',
113
117
  explain:
114
- '캐시를 만들고 재사용하지 못하고 있습니다. 세션이 자주 끊기거나 TTL이 만료된 새로 시작한 경우 흔합니다.',
118
+ 'The cache is being created but not reused. Common when sessions are short-lived or ' +
119
+ 'restarted after TTL expiry.',
115
120
  actions: () => [
116
121
  {
117
- label: '세션 지속 시간 확인',
118
- commands: [' 작업은 같은 Claude Code 세션에서 이어가세요'],
122
+ label: 'Check session continuity',
123
+ commands: ['Continue one task in one Claude Code session'],
119
124
  },
120
125
  ],
121
126
  },
@@ -124,15 +129,17 @@ export const ISSUE_MESSAGES = {
124
129
  /**
125
130
  * For the statusline: the single most relevant short chip (1~2 words).
126
131
  * Priority reflects what a user can act on *right now*.
132
+ * Kept short and English-only — these ride along on a single-line statusline
133
+ * shown to a global audience.
127
134
  */
128
135
  export function chipForIssues(issues, contextWindow) {
129
- if (contextWindow?.size === '1M') return '⚠ 1M컨텍스트';
136
+ if (contextWindow?.size === '1M') return '⚠ 1M ON';
130
137
  const codes = issues.map((i) => i.code);
131
- if (codes.includes('LARGE_INPUT_PER_REQUEST')) return '⚠ 입력폭주';
138
+ if (codes.includes('LARGE_INPUT_PER_REQUEST')) return '⚠ Input spike';
132
139
  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 '⚠ 호출폭주';
140
+ if (codes.includes('LOW_HIT_RATE')) return '⚠ Cache miss';
141
+ if (codes.includes('FREQUENT_CACHE_REBUILD')) return '⚠ Rebuild churn';
142
+ if (codes.includes('HIGH_OUTPUT_RATIO')) return '⚠ Output heavy';
143
+ if (codes.includes('HIGH_REQUEST_COUNT')) return '⚠ Call surge';
137
144
  return null;
138
145
  }
@@ -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
+ }
package/src/config.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * User-level config persistence — keeps the user's preferred statusline mode
3
+ * across runs without forcing them to edit ~/.claude/settings.json or any
4
+ * wrapper script. CLI flags (e.g. --icon) still override what's stored here.
5
+ *
6
+ * Location is resolved per-platform by paths.userDataDir():
7
+ * Windows: %APPDATA%\claude-token-saver\config.json
8
+ * macOS: ~/Library/Application Support/claude-token-saver/config.json
9
+ * Linux: $XDG_CONFIG_HOME/claude-token-saver/config.json or ~/.config/...
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { userDataDir } from './paths.js';
15
+
16
+ const CONFIG_DIR = userDataDir();
17
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
18
+
19
+ export function configPath() {
20
+ return CONFIG_PATH;
21
+ }
22
+
23
+ export function loadConfig() {
24
+ try {
25
+ if (!existsSync(CONFIG_PATH)) return {};
26
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) || {};
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ export function saveConfig(cfg) {
33
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
34
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
35
+ }
36
+
37
+ // Each keyword maps to a single statusline option toggle.
38
+ // Orthogonal — `mode icon verbose` flips both without resetting the rest.
39
+ const KEYWORDS = {
40
+ icon: { key: 'icon', value: true },
41
+ text: { key: 'icon', value: false },
42
+ verbose: { key: 'verbose', value: true },
43
+ compact: { key: 'verbose', value: false },
44
+ timer: { key: 'timer', value: true },
45
+ 'no-timer': { key: 'timer', value: false },
46
+ color: { key: 'color', value: true },
47
+ 'no-color': { key: 'color', value: false },
48
+ };
49
+
50
+ // Window preset accepts forms like:
51
+ // `1h`, `6h`, `24h` — hours
52
+ // `1d`, `7d`, `30d` — days (× 24h)
53
+ // `days=14`, `hours=6` — explicit
54
+ // Returns hours (number) or null if not a window keyword.
55
+ function parseWindow(word) {
56
+ const lower = String(word).toLowerCase();
57
+ const mh = lower.match(/^(\d+)h$/);
58
+ if (mh) return parseInt(mh[1], 10);
59
+ const md = lower.match(/^(\d+)d$/);
60
+ if (md) return parseInt(md[1], 10) * 24;
61
+ const eh = lower.match(/^hours?=(\d+)$/);
62
+ if (eh) return parseInt(eh[1], 10);
63
+ const ed = lower.match(/^days?=(\d+)$/);
64
+ if (ed) return parseInt(ed[1], 10) * 24;
65
+ return null;
66
+ }
67
+
68
+ export const VALID_KEYWORDS = Object.keys(KEYWORDS).concat([
69
+ '<N>h (e.g. 1h, 6h, 24h)',
70
+ '<N>d (e.g. 1d, 7d, 30d)',
71
+ 'reset',
72
+ 'default',
73
+ ]);
74
+
75
+ /**
76
+ * Apply user-supplied mode keywords to the persisted config.
77
+ * Returns { applied, unknown } so the caller can report success/failure.
78
+ */
79
+ export function applyMode(words) {
80
+ const cfg = loadConfig();
81
+ if (!cfg.statusline) cfg.statusline = {};
82
+
83
+ const applied = [];
84
+ const unknown = [];
85
+
86
+ for (const w of words) {
87
+ const lower = String(w).toLowerCase();
88
+ if (lower === 'reset' || lower === 'default') {
89
+ cfg.statusline = {};
90
+ applied.push(lower);
91
+ continue;
92
+ }
93
+ const hours = parseWindow(lower);
94
+ if (hours !== null && hours > 0) {
95
+ cfg.statusline.windowHours = hours;
96
+ // Drop legacy `days` field if present so a single source of truth wins.
97
+ delete cfg.statusline.days;
98
+ applied.push(formatWindow(hours));
99
+ continue;
100
+ }
101
+ const kw = KEYWORDS[lower];
102
+ if (!kw) {
103
+ unknown.push(w);
104
+ continue;
105
+ }
106
+ cfg.statusline[kw.key] = kw.value;
107
+ applied.push(lower);
108
+ }
109
+
110
+ if (applied.length && unknown.length === 0) saveConfig(cfg);
111
+ return { cfg, applied, unknown };
112
+ }
113
+
114
+ /**
115
+ * Effective statusline defaults, derived from the persisted config.
116
+ * Defaults for new users: icon=true, verbose=true, timer=true, color=true.
117
+ * Verbose+icon is the most readable preset (full labels + emoji anchors)
118
+ * and avoids the "1h bucket vs clock" ambiguity in compact mode.
119
+ * Users who explicitly opt out via `mode text` / `mode compact` get their
120
+ * choice persisted and respected.
121
+ */
122
+ export function statuslineDefaults() {
123
+ const s = loadConfig().statusline || {};
124
+ // windowHours is the source of truth. Legacy `days` field still honored
125
+ // for users with old configs.
126
+ let windowHours;
127
+ if (Number.isFinite(s.windowHours) && s.windowHours > 0) {
128
+ windowHours = s.windowHours;
129
+ } else if (Number.isFinite(s.days) && s.days > 0) {
130
+ windowHours = s.days * 24;
131
+ } else {
132
+ windowHours = 24; // default: last 1 day
133
+ }
134
+ return {
135
+ icon: s.icon !== false,
136
+ verbose: s.verbose !== false,
137
+ timer: s.timer !== false,
138
+ color: s.color !== false,
139
+ windowHours,
140
+ windowLabel: formatWindow(windowHours),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Render hours as the most natural unit:
146
+ * 24h → "1d", 168h → "7d", 6h → "6h", 36h → "36h" (not whole days).
147
+ */
148
+ export function formatWindow(hours) {
149
+ if (hours >= 24 && hours % 24 === 0) return `${hours / 24}d`;
150
+ return `${hours}h`;
151
+ }
package/src/demo.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Demo scenarios for the statusline — used for screencasts/marketing GIFs
3
+ * embedded in the GitHub README and npm page.
4
+ *
5
+ * Activated via:
6
+ * claude-token-saver --statusline --demo healthy
7
+ * claude-token-saver --statusline --demo cycle # rotates every 3s
8
+ *
9
+ * Each scenario builds the same data shape the real pipeline produces, so
10
+ * it flows through formatReport() unchanged.
11
+ */
12
+
13
+ const SCENARIOS = [
14
+ {
15
+ name: 'healthy',
16
+ label: '✅ Healthy baseline',
17
+ data: {
18
+ hitRate: 0.983,
19
+ pct1h: 0.95,
20
+ savings: 2123,
21
+ elapsedSec: 30,
22
+ contextSize: '200k',
23
+ spikeChip: null,
24
+ },
25
+ },
26
+ {
27
+ name: 'low-hit',
28
+ label: '⚠ Cache miss (low hit rate)',
29
+ data: {
30
+ hitRate: 0.55,
31
+ pct1h: 0.95,
32
+ savings: 240,
33
+ elapsedSec: 30,
34
+ contextSize: '200k',
35
+ spikeChip: '⚠ Cache miss',
36
+ },
37
+ },
38
+ {
39
+ name: 'ttl-warning',
40
+ label: '⚠ TTL nearly out (yellow)',
41
+ data: {
42
+ hitRate: 0.983,
43
+ pct1h: 0.95,
44
+ savings: 2123,
45
+ elapsedSec: 3000, // ~10min remaining of 1h
46
+ contextSize: '200k',
47
+ spikeChip: null,
48
+ },
49
+ },
50
+ {
51
+ name: 'ttl-expiring',
52
+ label: '⚠ TTL almost expired (red)',
53
+ data: {
54
+ hitRate: 0.983,
55
+ pct1h: 0.95,
56
+ savings: 2123,
57
+ elapsedSec: 3360, // ~4min remaining of 1h
58
+ contextSize: '200k',
59
+ spikeChip: null,
60
+ },
61
+ },
62
+ {
63
+ name: 'ttl-expired',
64
+ label: '⚠ TTL EXPIRED',
65
+ data: {
66
+ hitRate: 0.983,
67
+ pct1h: 0.95,
68
+ savings: 2123,
69
+ elapsedSec: 4000, // past 1h
70
+ contextSize: '200k',
71
+ spikeChip: null,
72
+ },
73
+ },
74
+ {
75
+ name: '5m-bucket',
76
+ label: '⚠ 5m TTL dominant (Pro plan)',
77
+ data: {
78
+ hitRate: 0.92,
79
+ pct1h: 0.15,
80
+ pct5m: 0.85,
81
+ savings: 410,
82
+ elapsedSec: 60,
83
+ contextSize: '200k',
84
+ spikeChip: '⚠ 5m TTL',
85
+ },
86
+ },
87
+ {
88
+ name: 'ctx-1m',
89
+ label: '⚠ 1M context auto-on',
90
+ data: {
91
+ hitRate: 0.78,
92
+ pct1h: 0.92,
93
+ savings: 1340,
94
+ elapsedSec: 30,
95
+ contextSize: '1M',
96
+ spikeChip: '⚠ 1M ON',
97
+ },
98
+ },
99
+ {
100
+ name: 'spike-input',
101
+ label: '⚠ Input spike',
102
+ data: {
103
+ hitRate: 0.91,
104
+ pct1h: 0.95,
105
+ savings: 1820,
106
+ elapsedSec: 30,
107
+ contextSize: '200k',
108
+ spikeChip: '⚠ Input spike',
109
+ },
110
+ },
111
+ {
112
+ name: 'spike-rebuild',
113
+ label: '⚠ Cache rebuild churn',
114
+ data: {
115
+ hitRate: 0.62,
116
+ pct1h: 0.85,
117
+ savings: 220,
118
+ elapsedSec: 30,
119
+ contextSize: '200k',
120
+ spikeChip: '⚠ Rebuild churn',
121
+ },
122
+ },
123
+ {
124
+ name: 'spike-output',
125
+ label: '⚠ Output ratio high',
126
+ data: {
127
+ hitRate: 0.94,
128
+ pct1h: 0.95,
129
+ savings: 1450,
130
+ elapsedSec: 30,
131
+ contextSize: '200k',
132
+ spikeChip: '⚠ Output heavy',
133
+ },
134
+ },
135
+ {
136
+ name: 'spike-calls',
137
+ label: '⚠ Request count surge',
138
+ data: {
139
+ hitRate: 0.93,
140
+ pct1h: 0.92,
141
+ savings: 980,
142
+ elapsedSec: 30,
143
+ contextSize: '200k',
144
+ spikeChip: '⚠ Call surge',
145
+ },
146
+ },
147
+ ];
148
+
149
+ export function listScenarios() {
150
+ return SCENARIOS.map((s) => ({ name: s.name, label: s.label }));
151
+ }
152
+
153
+ /**
154
+ * Synthetic spike-report data for `--demo table` — exercises every issue
155
+ * code so the drill-down section renders all six advice blocks. Used for
156
+ * marketing recordings of the table view.
157
+ */
158
+ export function buildTableDemoData(options = {}) {
159
+ const issueCodes = [
160
+ 'LARGE_INPUT_PER_REQUEST',
161
+ 'LOW_HIT_RATE',
162
+ 'BUCKET_5M_DOMINANT',
163
+ 'HIGH_OUTPUT_RATIO',
164
+ 'HIGH_REQUEST_COUNT',
165
+ 'FREQUENT_CACHE_REBUILD',
166
+ ];
167
+
168
+ const spikes = issueCodes.map((code, i) => ({
169
+ metrics: {
170
+ sessionId: `demo${String(i).padStart(4, '0')}-aaaa-bbbb`,
171
+ projectDir: ['ai-pipeline', 'frontend', 'data-eng', 'infra', 'docs-site', 'scratch'][i],
172
+ totalInput: [3_200_000, 850_000, 1_100_000, 620_000, 2_400_000, 740_000][i],
173
+ requestCount: [42, 128, 91, 67, 310, 58][i],
174
+ maxContextPerRequest: [280_000, 175_000, 195_000, 90_000, 145_000, 130_000][i],
175
+ },
176
+ ratio: [3.4, 2.1, 2.6, 1.9, 4.8, 2.3][i],
177
+ issues: [{ code }],
178
+ }));
179
+
180
+ return {
181
+ summary: {
182
+ sessions: 12,
183
+ apiCalls: 1247,
184
+ hitRate: 0.812,
185
+ totalInput: 9_540_000_000,
186
+ },
187
+ trend: [
188
+ { date: '2026-04-23', hitRate: 0.94, calls: 312, totalRead: 2.1e8, totalWrite: 8.2e6, pct5m: 0.08 },
189
+ { date: '2026-04-24', hitRate: 0.78, calls: 488, totalRead: 1.4e8, totalWrite: 1.5e7, pct5m: 0.42 },
190
+ { date: '2026-04-25', hitRate: 0.71, calls: 447, totalRead: 9.8e7, totalWrite: 2.1e7, pct5m: 0.61 },
191
+ ],
192
+ ttl: {
193
+ ephemeral5m: 1.5e7,
194
+ ephemeral1h: 2.4e7,
195
+ total: 3.9e7,
196
+ pct5m: 0.38,
197
+ pct1h: 0.62,
198
+ },
199
+ anomalies: [],
200
+ cost: {
201
+ tier: 'claude-opus-new',
202
+ actual: 487.32,
203
+ noCacheCost: 2143.91,
204
+ savings: 1656.59,
205
+ savingsRate: 0.773,
206
+ scenario5mCost: 612.04,
207
+ extraCostIf5m: 124.72,
208
+ },
209
+ options: {
210
+ days: options.days ?? 7,
211
+ windowHours: options.windowHours ?? 168,
212
+ windowLabel: options.windowLabel ?? '7d',
213
+ version: options.version ?? '',
214
+ },
215
+ spikeReport: { spikes, baseline: { p95: 940_000 } },
216
+ contextWindow: { size: '1M', maxContext: 280_000 },
217
+ lastActivity: Date.now() - 60 * 1000,
218
+ spikeChip: '⚠ 1M ON',
219
+ };
220
+ }
221
+
222
+
223
+ export function buildScenarioData(scenarioName, options) {
224
+ let scenario;
225
+ if (scenarioName === 'cycle') {
226
+ // Bucket Date.now() into N-second slots, rotate through scenarios.
227
+ const slot = Math.floor(Date.now() / (options.cycleSeconds * 1000)) % SCENARIOS.length;
228
+ scenario = SCENARIOS[slot];
229
+ } else {
230
+ scenario = SCENARIOS.find((s) => s.name === scenarioName);
231
+ if (!scenario) return null;
232
+ }
233
+
234
+ const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, spikeChip } = scenario.data;
235
+ return {
236
+ summary: { hitRate },
237
+ ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
238
+ cost: { savings },
239
+ options: {
240
+ days: options.days ?? 1,
241
+ windowHours: options.windowHours ?? 24,
242
+ windowLabel: options.windowLabel ?? '1d',
243
+ version: options.version ?? '',
244
+ },
245
+ lastActivity: Date.now() - elapsedSec * 1000,
246
+ contextWindow: { size: contextSize },
247
+ spikeChip,
248
+ _demoLabel: scenario.label,
249
+ _demoName: scenario.name,
250
+ };
251
+ }