claude-token-saver 2.0.2 → 2.1.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/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
+ }
@@ -87,25 +87,31 @@ export function formatReport(data, { color = true, verbose = false, timer = true
87
87
  : 'Cache hit';
88
88
  const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
89
89
 
90
- // text: "Cost saved $1.5K" | same in verbose
91
- // icon: "💰 $1.5K" | verbose: "💰 Cost saved $1.5K"
90
+ // text: "Cache saved $1.5K" | same in verbose
91
+ // icon: "💰 $1.5K" | verbose: "💰 Cache saved $1.5K"
92
92
  const saveLabel = isIcon
93
- ? (verbose ? '💰 Cost saved' : '💰')
94
- : 'Cost saved';
93
+ ? (verbose ? '💰 Cache saved' : '💰')
94
+ : 'Cache saved';
95
95
  const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
96
96
 
97
+ // Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
98
+ // Fall back to legacy `${days}d` when callers haven't supplied a label.
99
+ const periodLabel = options.windowLabel || `${options.days}d`;
97
100
  const periodSeg = verbose
98
- ? `${c(GRAY)}last ${options.days}d${c(RESET)}`
99
- : `${c(GRAY)}${options.days}d${c(RESET)}`;
101
+ ? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
102
+ : `${c(GRAY)}${periodLabel}${c(RESET)}`;
100
103
 
101
104
  // TTL countdown — how much time is left on the last API call's cache entry.
102
105
  // Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
103
106
  // TTL window, and the next call (hit) within that window resets it. So the
104
107
  // countdown visibly ticks down between prompts, and "resets" happens as a
105
108
  // jump back toward the bucket max the moment you send another message.
106
- // text compact: "Expires 1h 59:58"
109
+ // Compact modes drop the bucket label — it's read as part of the clock
110
+ // ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
111
+ // is plan-determined and rarely changes, so verbose mode is where it belongs.
112
+ // text compact: "Expires 59:58"
107
113
  // text verbose: "1h bucket · expires in 59:58"
108
- // icon compact: "⏳ 1h 59:58"
114
+ // icon compact: "⏳ 59:58"
109
115
  // icon verbose: "⏳ Expires 1h 59:58"
110
116
  let ttlSeg;
111
117
  if (timer && lastActivity) {
@@ -119,22 +125,28 @@ export function formatReport(data, { color = true, verbose = false, timer = true
119
125
  pct > 0.10 ? YELLOW :
120
126
  RED;
121
127
 
122
- if (isIcon) {
123
- const prefix = verbose ? '⏳ Expires ' : '⏳ ';
124
- ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)} ${c(timerColor)}${text}${c(RESET)}`;
128
+ if (isIcon && verbose) {
129
+ // Drop bucket here too `⏳ Expires 1h 57:20` reads as "1h 57m 20s left"
130
+ // for the same reason the compact form did. The bucket lives in the
131
+ // text-verbose layout where the "bucket" word + `·` separator make it
132
+ // unambiguous.
133
+ ttlSeg = `${c(timerColor)}⏳ Cache expires ${text}${c(RESET)}`;
134
+ } else if (isIcon) {
135
+ ttlSeg = `${c(timerColor)}⏳ ${text}${c(RESET)}`;
125
136
  } else if (verbose) {
126
- ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
137
+ ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
127
138
  } else {
128
- ttlSeg = `${c(bucketColor)}Expires ${bucketLabel}${c(RESET)} ${c(timerColor)}${text}${c(RESET)}`;
139
+ ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
129
140
  }
130
141
  } else {
142
+ // No-timer fallback: only the bucket is available, so we show just that.
131
143
  if (isIcon) {
132
- const prefix = verbose ? '⏳ Bucket ' : '⏳ ';
144
+ const prefix = verbose ? '⏳ Cache bucket ' : '⏳ ';
133
145
  ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
134
146
  } else if (verbose) {
135
- ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)}`;
147
+ ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
136
148
  } else {
137
- ttlSeg = `${c(bucketColor)}Bucket ${bucketLabel}${c(RESET)}`;
149
+ ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
138
150
  }
139
151
  }
140
152
 
@@ -144,7 +156,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
144
156
  if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
145
157
  const label = contextWindow.size === '1M' ? '1M' : '200k';
146
158
  const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
147
- if (isIcon) {
159
+ if (isIcon && verbose) {
160
+ ctxSeg = `${c(ctxColor)}📦 Context ${label}${c(RESET)}`;
161
+ } else if (isIcon) {
148
162
  ctxSeg = `${c(ctxColor)}📦 ${label}${c(RESET)}`;
149
163
  } else if (verbose) {
150
164
  ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
@@ -156,9 +170,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
156
170
  // Spike chip — one word only, keeps the statusline single-line.
157
171
  const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
158
172
 
159
- const segs = [hitSeg, ttlSeg, saveSeg];
160
- if (ctxSeg) segs.push(ctxSeg);
173
+ // Warning chip leads a glance at the statusline catches "something's wrong"
174
+ // before parsing any numbers. Healthy states have no chip and look unchanged.
175
+ const segs = [];
161
176
  if (spikeSeg) segs.push(spikeSeg);
177
+ segs.push(hitSeg, ttlSeg, saveSeg);
178
+ if (ctxSeg) segs.push(ctxSeg);
162
179
  segs.push(periodSeg);
163
180
  return segs.join(' · ');
164
181
  }
@@ -64,11 +64,11 @@ function formatContextSize(n) {
64
64
 
65
65
  function renderSpikeSection(spikes, contextWindow) {
66
66
  const lines = [];
67
- lines.push(' ⚠ 토큰 급증 감지');
67
+ lines.push(' ⚠ Token spike detected');
68
68
  lines.push(` ${'─'.repeat(50)}`);
69
69
  if (contextWindow && contextWindow.size === '1M') {
70
70
  lines.push(
71
- ` 컨텍스트 모드 추정: 1M (최근 단일 요청 최대 ${formatContextSize(contextWindow.maxContext)} 토큰)`,
71
+ ` Context mode: 1M (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`,
72
72
  );
73
73
  lines.push('');
74
74
  }
@@ -77,11 +77,11 @@ function renderSpikeSection(spikes, contextWindow) {
77
77
  const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
78
78
  lines.push(
79
79
  ` • ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] ` +
80
- `총 입력 ${formatContextSize(m.totalInput)} (${ratioLabel}, 요청 ${m.requestCount})`,
80
+ `total input ${formatContextSize(m.totalInput)} (${ratioLabel}, ${m.requestCount} requests)`,
81
81
  );
82
82
  if (m.maxContextPerRequest > 0) {
83
83
  lines.push(
84
- ` 단일 요청 최대 컨텍스트: ${formatContextSize(m.maxContextPerRequest)} 토큰`,
84
+ ` max single-request context: ${formatContextSize(m.maxContextPerRequest)} tokens`,
85
85
  );
86
86
  }
87
87
  for (const issue of spike.issues) {
@@ -104,7 +104,7 @@ function renderSpikeSection(spikes, contextWindow) {
104
104
  }
105
105
  }
106
106
  if (uniqueIssues.length > 0) {
107
- lines.push(' 권장 액션');
107
+ lines.push(' Recommended actions');
108
108
  lines.push(` ${'─'.repeat(50)}`);
109
109
  for (const issue of uniqueIssues) {
110
110
  const info = ISSUE_MESSAGES[issue.code];
@@ -128,7 +128,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
128
128
 
129
129
  // Header
130
130
  lines.push('');
131
- lines.push(` Claude 토큰 아껴쓰기 — Last ${options.days} days`);
131
+ lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
132
132
  lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
133
133
  lines.push(` ${'═'.repeat(50)}`);
134
134
  lines.push('');
@@ -142,10 +142,10 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
142
142
  if (contextWindow && contextWindow.size !== 'unknown') {
143
143
  const note =
144
144
  contextWindow.size === '1M'
145
- ? '⚠ 1M 컨텍스트 사용 (Opus 4.7+ Max 기본값). 필요 없으면 CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
146
- : '✓ 200k 컨텍스트 (표준)';
145
+ ? '⚠ 1M context active (Opus 4.7+ Max default). Disable with CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
146
+ : '✓ 200k context (standard)';
147
147
  lines.push(` Context window: ${contextWindow.size} ${note}`);
148
- lines.push(` (최근 단일 요청 최대 ${formatContextSize(contextWindow.maxContext)} 토큰)`);
148
+ lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
149
149
  lines.push('');
150
150
  }
151
151
 
package/src/history.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Warning history — appends an entry whenever the active chip transitions
3
+ * (none → warning, warning A → warning B, warning → none). One markdown file
4
+ * per calendar day so users can pinpoint "when did this start" easily.
5
+ *
6
+ * Storage path is platform-aware (see paths.userDataDir):
7
+ * Windows: %APPDATA%\claude-token-saver\history\YYYY-MM-DD.md
8
+ * macOS: ~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md
9
+ * Linux: ~/.config/claude-token-saver/history/YYYY-MM-DD.md
10
+ *
11
+ * State (last-seen chip, prevents duplicate appends every 1s refresh):
12
+ * <userDataDir>/last-chip.json
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { userDataDir } from './paths.js';
18
+
19
+ const BASE_DIR = userDataDir();
20
+ const HISTORY_DIR = join(BASE_DIR, 'history');
21
+ const STATE_PATH = join(BASE_DIR, 'last-chip.json');
22
+
23
+ export function historyDir() {
24
+ return HISTORY_DIR;
25
+ }
26
+
27
+ function ensureDir(p) {
28
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
29
+ }
30
+
31
+ function pad(n) {
32
+ return String(n).padStart(2, '0');
33
+ }
34
+
35
+ function ymd(d = new Date()) {
36
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
37
+ }
38
+
39
+ function hms(d = new Date()) {
40
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
41
+ }
42
+
43
+ function loadState() {
44
+ try {
45
+ return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
46
+ } catch {
47
+ return { chip: null, ts: null };
48
+ }
49
+ }
50
+
51
+ function saveState(state) {
52
+ ensureDir(BASE_DIR);
53
+ writeFileSync(STATE_PATH, JSON.stringify(state) + '\n');
54
+ }
55
+
56
+ function appendDayLine(line, date = new Date()) {
57
+ ensureDir(HISTORY_DIR);
58
+ const path = join(HISTORY_DIR, `${ymd(date)}.md`);
59
+ if (!existsSync(path)) {
60
+ writeFileSync(path, `# Token Monitor — ${ymd(date)}\n\n## Events\n${line}\n`);
61
+ } else {
62
+ const existing = readFileSync(path, 'utf8');
63
+ writeFileSync(path, existing.endsWith('\n') ? existing + line + '\n' : existing + '\n' + line + '\n');
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Record a chip transition. Called from the statusline render path.
69
+ * Returns `true` if a transition was logged, `false` if duplicate (same chip
70
+ * as last call).
71
+ */
72
+ export function recordChip(chip, contextHints = {}) {
73
+ const state = loadState();
74
+ const now = new Date();
75
+ const current = chip || null;
76
+ const last = state.chip || null;
77
+
78
+ if (current === last) return false;
79
+
80
+ let line;
81
+ if (current && !last) {
82
+ line = `- ${hms(now)} ${current}` + (contextHints.detail ? ` — ${contextHints.detail}` : '');
83
+ } else if (current && last) {
84
+ line = `- ${hms(now)} ${last} → ${current}` + (contextHints.detail ? ` — ${contextHints.detail}` : '');
85
+ } else {
86
+ // current === null, last was something — warning resolved
87
+ line = `- ${hms(now)} ✓ resolved (was ${last})`;
88
+ }
89
+ appendDayLine(line, now);
90
+ saveState({ chip: current, ts: now.toISOString() });
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Read history files for the most recent N days (oldest first).
96
+ * Returns array of { date, content } — empty content for days with no file.
97
+ */
98
+ export function readRecent(days = 7) {
99
+ ensureDir(HISTORY_DIR);
100
+ const out = [];
101
+ for (let i = days - 1; i >= 0; i--) {
102
+ const d = new Date();
103
+ d.setDate(d.getDate() - i);
104
+ const date = ymd(d);
105
+ const path = join(HISTORY_DIR, `${date}.md`);
106
+ if (existsSync(path)) {
107
+ out.push({ date, content: readFileSync(path, 'utf8') });
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * List all available history file dates (sorted newest first).
115
+ */
116
+ export function listDates() {
117
+ ensureDir(HISTORY_DIR);
118
+ return readdirSync(HISTORY_DIR)
119
+ .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
120
+ .map((f) => f.replace(/\.md$/, ''))
121
+ .sort()
122
+ .reverse();
123
+ }