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/README.md +81 -23
- package/bin/cli.js +340 -11
- package/package.json +1 -1
- package/src/advice.js +48 -41
- package/src/caps-cache.js +51 -0
- package/src/config.js +151 -0
- package/src/demo.js +251 -0
- package/src/formatters/statusline.js +102 -23
- package/src/formatters/table.js +51 -11
- package/src/handoff.js +162 -0
- package/src/history.js +258 -0
- package/src/installer.js +150 -0
- package/src/paths.js +41 -0
- package/examples/statusline-with-rz1989s.sh +0 -52
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: '
|
|
31
|
+
title: 'Per-request input tokens are unusually large (1M context suspected)',
|
|
32
32
|
explain:
|
|
33
|
-
'Opus 4.7
|
|
34
|
-
'
|
|
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
|
|
37
|
+
label: 'Disable 1M context (env var)',
|
|
38
38
|
commands: disable1mEnvSnippet(),
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
|
-
label: '
|
|
42
|
-
commands: [
|
|
41
|
+
label: 'In-session toggle',
|
|
42
|
+
commands: [`Press ${toggleShortcut()} to toggle on/off instantly`],
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
|
-
label: '⚠
|
|
45
|
+
label: '⚠ Known bug #31640',
|
|
46
46
|
commands: [
|
|
47
|
-
'/model
|
|
48
|
-
'
|
|
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
|
-
'
|
|
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: '
|
|
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
|
|
70
|
+
title: 'Most cache writes are landing in the 5-minute TTL bucket',
|
|
70
71
|
explain:
|
|
71
|
-
'Pro
|
|
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
|
|
77
|
-
'
|
|
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
|
|
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
|
-
'
|
|
91
|
-
'
|
|
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: '
|
|
100
|
+
title: 'API calls in this session are 3x+ the baseline',
|
|
98
101
|
explain:
|
|
99
|
-
'
|
|
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
|
-
'
|
|
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: ['
|
|
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
|
+
}
|