claude-token-saver 3.30.1 β†’ 3.31.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.en.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
4
4
 
5
+ 🌐 **[Project page](https://rootstudioyaml.github.io/claude-token-saver/)**
6
+
5
7
  # claude-token-saver
6
8
 
7
9
  **Shows what it saved, on two lines.** It moves the easy work your expensive model keeps repeating onto cheaper ones, and turns documents the model cannot read into Markdown. Both figures are ledger entries rather than estimates, and whichever saved more takes the top line. Zero dependencies, one-line install.
@@ -214,6 +216,7 @@ Bootstrap five engineering principles (Ratchet Β· Evidence Β· PEV Β· Structured
214
216
  claude-token-saver harness init # this project
215
217
  claude-token-saver harness init --global # ~/.claude/CLAUDE.md β€” every project
216
218
  claude-token-saver harness check # current score (global fallback honored)
219
+ claude-token-saver harness analyze # run the transcript analysis manually (no hook needed); refreshes harness-state.json
217
220
  claude-token-saver harness promote <N> --project|--global # warning #N β†’ ratchet rule (scope required)
218
221
  claude-token-saver harness promote "<rule text>" --project|--global # register your own hand-written rules the same way
219
222
  claude-token-saver harness pull # register the package's curated ratchet rules into your global ratchet (opt-in, dedupes)
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
4
4
 
5
+ 🌐 **[ν”„λ‘œμ νŠΈ μ†Œκ°œ νŽ˜μ΄μ§€](https://rootstudioyaml.github.io/claude-token-saver/)**
6
+
5
7
  # claude-token-saver
6
8
 
7
9
  **μ•„λ‚€ λˆμ„ 두 μ€„λ‘œ 보여 μ€λ‹ˆλ‹€.** λΉ„μ‹Ό λͺ¨λΈμ΄ λ°˜λ³΅ν•˜λ˜ μ‰¬μš΄ μž‘μ—…μ„ μ‹Ό λͺ¨λΈλ‘œ 내렀보내고, λͺ¨λΈμ΄ 읽지 λͺ»ν•˜λŠ” λ¬Έμ„œλ₯Ό Markdown 으둜 λ°”κΏ‰λ‹ˆλ‹€. 두 μ ˆκ°μ•‘ λͺ¨λ‘ 좔정이 μ•„λ‹ˆλΌ 원μž₯ 기둝이고, 더 많이 μ•„λ‚€ μͺ½μ΄ 첫 쀄을 μ°¨μ§€ν•©λ‹ˆλ‹€. μ˜μ‘΄μ„± 0, μ„€μΉ˜ ν•œ 쀄.
@@ -194,6 +196,7 @@ statusline은 λŒ€ν™” μƒμžλ₯Ό λ„μšΈ 수 μ—†κ³ , 300λ°€λ¦¬μ΄ˆλ§ˆλ‹€ λ‹€μ‹œ κ·Έ
194
196
  claude-token-saver harness init # 이 ν”„λ‘œμ νŠΈμ— μ…‹μ—…
195
197
  claude-token-saver harness init --global # ~/.claude/CLAUDE.md, λͺ¨λ“  ν”„λ‘œμ νŠΈμ— 적용
196
198
  claude-token-saver harness check # ν˜„μž¬ 점수 (κΈ€λ‘œλ²Œ fallback 인정)
199
+ claude-token-saver harness analyze # ν›… 없이도 μˆ˜λ™μœΌλ‘œ 전사 뢄석을 μ‹€ν–‰ν•΄ harness-state.json κ°±μ‹ 
197
200
  claude-token-saver harness promote <N> --project|--global # κ²½κ³  #N β†’ ratchet λ£° (μŠ€μ½”ν”„ ν•„μˆ˜)
198
201
  claude-token-saver harness promote "<λ£° ν…μŠ€νŠΈ>" --project|--global # λ‚΄κ°€ 직접 μ •μ˜ν•œ 룰도 같은 λͺ…λ ΉμœΌλ‘œ 등둝
199
202
  claude-token-saver harness pull # νŒ¨ν‚€μ§€ 동봉 νλ ˆμ΄μ…˜ λ£° β†’ λ‚΄ κΈ€λ‘œλ²Œ λž«μ³‡μ— 등둝 (opt-in, 쀑볡 μŠ€ν‚΅)
package/bin/cli.js CHANGED
@@ -89,7 +89,40 @@ const KNOWN_SUBCOMMANDS = new Set([
89
89
  'doc2md', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
90
90
  ]);
91
91
 
92
+ const USAGE = `claude-token-saver β€” Claude Code token usage, cache health, and model routing
93
+
94
+ Usage:
95
+ claude-token-saver default report (last 30 days)
96
+ claude-token-saver --days 7 last 7 days
97
+ claude-token-saver --format json JSON output
98
+ claude-token-saver --format csv CSV output
99
+ claude-token-saver --project myproj filter by project
100
+ claude-token-saver route-scan detect recurring easy work β†’ delegation candidates
101
+ claude-token-saver install set up skill/hooks/statusline
102
+ claude-token-saver install --yes take the defaults without asking
103
+ claude-token-saver uninstall remove everything install added
104
+ claude-token-saver harness check score the harness setup in CLAUDE.md
105
+ claude-token-saver harness analyze run the harness transcript analysis manually
106
+ claude-token-saver last most recent warning + how to handle it
107
+ claude-token-saver history recent warning transitions
108
+ claude-token-saver handoff write a session handoff file
109
+ claude-token-saver upgrade install the latest release
110
+ claude-token-saver --install-hook install cache-monitor PostToolUse hook
111
+ claude-token-saver --uninstall-hook remove that hook
112
+ claude-token-saver --statusline one-line output for Claude Code statusline
113
+ --verbose / --no-color / --icon / --no-timer / --single-line
114
+
115
+ Run any subcommand with --help for its own options where available.
116
+ `;
117
+
92
118
  async function main() {
119
+ // Help must never fall through to the default report β€” that runs a full
120
+ // 30-day scan, which is the opposite of what someone asking for help wants.
121
+ if (hasFlag('--help') || hasFlag('-h') || args[0] === 'help') {
122
+ process.stdout.write(USAGE);
123
+ return;
124
+ }
125
+
93
126
  // A hook invocation names a subcommand and expects either silence or that
94
127
  // subcommand's own protocol on stdout. If this build does not have the
95
128
  // subcommand β€” an older global install against a newer settings.json, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.30.1",
3
+ "version": "3.31.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet β€” post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/brief.js CHANGED
@@ -227,7 +227,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
227
227
  for (const [kind, x] of fresh) {
228
228
  items.push(kind === 'route'
229
229
  ? `μƒˆ μœ„μž„ 후보가 κ°μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€ β€” "${x.label}" μœ ν˜• ${x.count}회 반볡(statusline의 route? R${x.id} μΉ©). 등둝: claude-token-saver harness promote R${x.id} --project|--global (적용 λ²”μœ„λŠ” μ‚¬μš©μžμ—κ²Œ 확인) / λ¬΄μ‹œ: route-scan dismiss ${x.id}`
230
- : `승인된 μœ„μž„ 룰의 졜근 μ—λŸ¬μœ¨μ΄ κΈ°μ€€(20%)을 λ„˜μ—ˆμŠ΅λ‹ˆλ‹€ β€” "${x.label}" (statusline의 rule-health μΉ©). 쑰건 쒁히기/제거λ₯Ό μ‚¬μš©μžμ™€ μƒμ˜ν•˜μ„Έμš”: claude-token-saver route-scan rules`);
230
+ : `승인된 μœ„μž„ 룰의 졜근 μ—λŸ¬μœ¨μ΄ κΈ°μ€€(20%)을 λ„˜μ—ˆμŠ΅λ‹ˆλ‹€ β€” "${x.label}" (${x.healthSource === 'delegated' ? `μœ„μž„ μ‹€ν–‰ ${x.delegatedRuns}건 쀑 μ—λŸ¬μœ¨ ${Math.round((x.delegatedErrRate || 0) * 100)}%` : `μœ μ‚¬ μ—ν”Όμ†Œλ“œ κΈ°μ€€ μ—λŸ¬μœ¨ ${Math.round((x.errRate || 0) * 100)}%`}, statusline의 rule-health μΉ©). 쑰건 쒁히기/제거λ₯Ό μ‚¬μš©μžμ™€ μƒμ˜ν•˜μ„Έμš”: claude-token-saver route-scan rules`);
231
231
  }
232
232
  s.briefed = [...briefed];
233
233
  } catch { /* caches unreadable β€” ctx briefing above still applies */ }
package/src/cost.js CHANGED
@@ -125,7 +125,12 @@ const TIER_RANK = {
125
125
  * from the ranking instead of guessed at.
126
126
  */
127
127
  export function isUnknownModel(model) {
128
- return !model || String(model).toLowerCase() === 'unknown';
128
+ if (!model) return true;
129
+ const m = String(model).toLowerCase();
130
+ // "<synthetic>" is Claude Code's placeholder for locally-generated error
131
+ // stubs β€” no real API call happened, so pricing it as Sonnet would be a
132
+ // silent guess (session-records.js skips it for the same reason).
133
+ return m === 'unknown' || m === '<synthetic>';
129
134
  }
130
135
 
131
136
  /**
@@ -51,7 +51,11 @@ const GRAY = fg(100, 116, 139, '\x1b[90m');
51
51
  const BOLD = '\x1b[1m';
52
52
 
53
53
  function formatMoney(usd) {
54
- if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}K`;
54
+ // Boundaries sit at 999.5/999500 so a value that would round up into the
55
+ // next band's width ($999.9 β†’ "$1000") jumps to that band's unit instead β€”
56
+ // otherwise "$1000" renders visually larger than "$1.0K".
57
+ if (usd >= 999500) return `$${(usd / 1e6).toFixed(1)}M`;
58
+ if (usd >= 999.5) return `$${(usd / 1000).toFixed(1)}K`;
55
59
  if (usd >= 100) return `$${usd.toFixed(0)}`;
56
60
  if (usd >= 10) return `$${usd.toFixed(1)}`;
57
61
  return `$${usd.toFixed(2)}`;
@@ -39,15 +39,20 @@ const PEV_TOOLUSE_THRESHOLD = 5;
39
39
  // researching, which is exactly the behavior we don't want to punish.
40
40
  const MUTATING_TOOL_RE = /^(edit|write|multiedit|notebookedit|bash)$/i;
41
41
 
42
+ // Mirrors src/paths.js userDataDir() exactly (same order as doc2md.cjs).
43
+ // Duplicated because this file is CommonJS and paths.js is ESM; the
44
+ // precedence must match or state lands where the rest of the tool won't look.
42
45
  function stateDir() {
43
- if (process.platform === 'win32') {
44
- return path.join(process.env.APPDATA || os.homedir(), 'claude-token-saver');
46
+ if (process.env.XDG_CONFIG_HOME) {
47
+ return path.join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
48
+ }
49
+ if (process.platform === 'win32' && process.env.APPDATA) {
50
+ return path.join(process.env.APPDATA, 'claude-token-saver');
45
51
  }
46
52
  if (process.platform === 'darwin') {
47
53
  return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
48
54
  }
49
- const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
50
- return path.join(xdg, 'claude-token-saver');
55
+ return path.join(os.homedir(), '.config', 'claude-token-saver');
51
56
  }
52
57
 
53
58
  function readJsonl(file) {
@@ -1,21 +1,31 @@
1
1
  /**
2
- * Install/uninstall the SessionEnd hook in ~/.claude/settings.json
2
+ * Install/uninstall the cache-monitor PostToolUse hook in ~/.claude/settings.json
3
+ *
4
+ * Registered as a CLI subcommand (`claude-token-saver --hook-run`) like every
5
+ * other hook in this package β€” NOT as a copied file. The old copy-to-home
6
+ * approach pinned a stale hook.cjs in ~/.claude/ forever, never shipped
7
+ * harness-analyzer.cjs alongside it, and survived `uninstall` because its
8
+ * command line did not contain the CLI name. Registering the CLI itself
9
+ * removes the copy, the staleness, and the orphan in one move.
3
10
  */
4
11
 
5
- import { readFile, writeFile, copyFile } from 'node:fs/promises';
6
- import { join, dirname } from 'node:path';
12
+ import { readFile, writeFile, rm } from 'node:fs/promises';
13
+ import { join } from 'node:path';
7
14
  import { homedir } from 'node:os';
8
- import { fileURLToPath } from 'node:url';
9
15
 
10
16
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
11
- const HOOK_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'hook.cjs');
12
- const HOOK_DEST = join(homedir(), '.claude', 'cache-monitor-hook.cjs');
17
+ // Legacy copied-file location β€” removed on install/uninstall so machines that
18
+ // installed an older version don't keep a dead hook script around.
19
+ const LEGACY_HOOK_DEST = join(homedir(), '.claude', 'cache-monitor-hook.cjs');
13
20
  const HOOK_MARKER = 'cache-monitor-hook';
14
21
 
15
- export async function installHook({ threshold = 0.7 } = {}) {
16
- // Copy hook script to ~/.claude/ for stable path
17
- await copyFile(HOOK_SCRIPT, HOOK_DEST);
22
+ function isCacheMonitorHook(nh) {
23
+ const cmd = nh?.command;
24
+ if (typeof cmd !== 'string') return false;
25
+ return cmd.includes(HOOK_MARKER) || cmd.includes('--hook-run');
26
+ }
18
27
 
28
+ export async function installHook({ threshold = 0.7 } = {}) {
19
29
  let settings;
20
30
  try {
21
31
  const raw = await readFile(SETTINGS_PATH, 'utf8');
@@ -27,24 +37,18 @@ export async function installHook({ threshold = 0.7 } = {}) {
27
37
  if (!settings.hooks) settings.hooks = {};
28
38
  if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
29
39
 
30
- // Remove existing cache-monitor hook if present
40
+ // Remove existing cache-monitor hook if present β€” matches both the current
41
+ // subcommand form and the legacy copied-file form.
31
42
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
32
- (h) => {
33
- // Check nested hooks structure
34
- const nested = h.hooks || [];
35
- return !nested.some((nh) => nh.command?.includes(HOOK_MARKER));
36
- },
43
+ (h) => !(h.hooks || []).some(isCacheMonitorHook),
37
44
  );
38
45
 
39
- // Add new hook (correct 3-level nested structure)
40
- // Normalize path separators for Windows compatibility in shell commands
41
- const hookPath = HOOK_DEST.replace(/\\/g, '/');
42
46
  settings.hooks.PostToolUse.push({
43
47
  matcher: 'Bash|Edit|Write',
44
48
  hooks: [
45
49
  {
46
50
  type: 'command',
47
- command: `node "${hookPath}" --threshold ${threshold}`,
51
+ command: `claude-token-saver --hook-run --threshold ${threshold}`,
48
52
  timeout: 10,
49
53
  },
50
54
  ],
@@ -52,7 +56,10 @@ export async function installHook({ threshold = 0.7 } = {}) {
52
56
 
53
57
  await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
54
58
 
55
- console.log(`βœ“ Hook installed at ${HOOK_DEST}`);
59
+ // Clean up the legacy copy left by older versions.
60
+ await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
61
+
62
+ console.log('βœ“ Hook installed (PostToolUse β†’ claude-token-saver --hook-run)');
56
63
  console.log(` Settings updated: ${SETTINGS_PATH}`);
57
64
  console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
58
65
  console.log(` Stats file: ~/.claude/cache-stats.jsonl`);
@@ -71,10 +78,7 @@ export async function uninstallHook() {
71
78
  if (settings.hooks?.PostToolUse) {
72
79
  const before = settings.hooks.PostToolUse.length;
73
80
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
74
- (h) => {
75
- const nested = h.hooks || [];
76
- return !nested.some((nh) => nh.command?.includes(HOOK_MARKER));
77
- },
81
+ (h) => !(h.hooks || []).some(isCacheMonitorHook),
78
82
  );
79
83
  const removed = before - settings.hooks.PostToolUse.length;
80
84
 
@@ -86,4 +90,7 @@ export async function uninstallHook() {
86
90
  } else {
87
91
  console.log('No cache-monitor hook found in settings.');
88
92
  }
93
+
94
+ // The legacy copied hook file is dead weight either way.
95
+ await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
89
96
  }
package/src/hook.cjs CHANGED
@@ -12,6 +12,15 @@
12
12
 
13
13
  'use strict';
14
14
 
15
+ // CJS twin of src/debug.js \u2014 hook failures must stay silent for the session
16
+ // but be visible under CTS_DEBUG=1, otherwise a broken path is
17
+ // indistinguishable from "nothing to do".
18
+ var CTS_DEBUG = !!process.env.CTS_DEBUG;
19
+ function dbg(scope, err) {
20
+ if (!CTS_DEBUG) return;
21
+ process.stderr.write('[cts:' + scope + '] ' + ((err && err.stack) || String(err)) + '\n');
22
+ }
23
+
15
24
  const fs = require('node:fs');
16
25
  const path = require('node:path');
17
26
  const os = require('node:os');
@@ -30,14 +39,15 @@ if (thIdx !== -1 && process.argv[thIdx + 1]) {
30
39
  let stdin = '';
31
40
  try {
32
41
  stdin = fs.readFileSync(0, 'utf8');
33
- } catch {
34
- // no stdin
42
+ } catch (e) {
43
+ dbg('hook:stdin', e);
35
44
  }
36
45
 
37
46
  let context;
38
47
  try {
39
48
  context = JSON.parse(stdin);
40
- } catch {
49
+ } catch (e) {
50
+ dbg('hook:parse-stdin', e);
41
51
  process.exit(0);
42
52
  }
43
53
 
@@ -52,8 +62,8 @@ function resolveSessionFile() {
52
62
  try {
53
63
  fs.statSync(context.transcript_path);
54
64
  return context.transcript_path;
55
- } catch {
56
- // path provided but file not found, try fallback
65
+ } catch (e) {
66
+ dbg('hook:transcript-path', e);
57
67
  }
58
68
  }
59
69
 
@@ -69,8 +79,8 @@ function resolveSessionFile() {
69
79
  // not here
70
80
  }
71
81
  }
72
- } catch {
73
- // no projects dir
82
+ } catch (e) {
83
+ dbg('hook:projects-dir', e);
74
84
  }
75
85
 
76
86
  return null;
@@ -83,7 +93,8 @@ if (!sessionFile) process.exit(0);
83
93
  let content;
84
94
  try {
85
95
  content = fs.readFileSync(sessionFile, 'utf8');
86
- } catch {
96
+ } catch (e) {
97
+ dbg('hook:read-session', e);
87
98
  process.exit(0);
88
99
  }
89
100
 
@@ -155,8 +166,8 @@ const record = {
155
166
  // Append to stats file
156
167
  try {
157
168
  fs.appendFileSync(STATS_FILE, JSON.stringify(record) + '\n', 'utf8');
158
- } catch {
159
- // can't write, ignore
169
+ } catch (e) {
170
+ dbg('hook:append-stats', e);
160
171
  }
161
172
 
162
173
  // Alert if hit rate below threshold
@@ -179,6 +190,7 @@ try {
179
190
  cwd: cwd,
180
191
  });
181
192
  if (state) harnessAnalyzer.writeState(state);
182
- } catch (_) {
193
+ } catch (e) {
194
+ dbg('hook:harness-analyzer', e);
183
195
  // analyzer is purely advisory \u2014 never break the hook on failure
184
196
  }
package/src/installer.js CHANGED
@@ -88,6 +88,11 @@ honor that for the rest of the turn without changing the saved setting.
88
88
  | \`⚠ Output heavy\` | Output ratio dominates input β€” inspect long generations. |
89
89
  | \`⚠ Call surge\` | Request count is well above baseline. |
90
90
 
91
+ Note: the \`🚨 5H/7D\` cap chips come from Claude Code's own
92
+ \`rate_limits\` payload, which gateway backends (Bedrock, Vertex,
93
+ LiteLLM) do not provide β€” on those setups the chips never appear, and
94
+ that is expected, not a failure of this tool.
95
+
91
96
  6. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
92
97
  \`claude-token-saver handoff\` to back up the current work to a
93
98
  \`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
@@ -150,8 +155,8 @@ export function removeLegacyCommand() {
150
155
  }
151
156
 
152
157
  // Registers/repairs the Claude Code statusLine entry in ~/.claude/settings.json.
153
- // - No statusLine yet: insert ours with refreshInterval:1.
154
- // - statusLine already points at claude-token-saver: ensure refreshInterval:1
158
+ // - No statusLine yet: insert ours with refreshInterval:STATUSLINE_REFRESH_INTERVAL.
159
+ // - statusLine already points at claude-token-saver: ensure that refreshInterval
155
160
  // (this is the bit that makes the TTL countdown tick every second while idle).
156
161
  // - statusLine points at a different command: leave it alone unless --force.
157
162
  export function installStatusline({ force = false } = {}) {
@@ -187,7 +192,7 @@ export function installStatusline({ force = false } = {}) {
187
192
  }
188
193
  cur.refreshInterval = STATUSLINE_REFRESH_INTERVAL;
189
194
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
190
- return { path: file, action: 'updated', reason: 'set refreshInterval=1' };
195
+ return { path: file, action: 'updated', reason: `set refreshInterval=${STATUSLINE_REFRESH_INTERVAL}` };
191
196
  }
192
197
 
193
198
  if (!force) {
@@ -512,7 +517,8 @@ export function uninstallAll({ purge = false } = {}) {
512
517
  if (!Array.isArray(list)) continue;
513
518
  const kept = list.filter((m) => !(
514
519
  Array.isArray(m?.hooks)
515
- && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('claude-token-saver'))
520
+ && m.hooks.some((h) => typeof h?.command === 'string'
521
+ && (h.command.includes('claude-token-saver') || h.command.includes('cache-monitor-hook')))
516
522
  ));
517
523
  if (kept.length === list.length) continue;
518
524
  result.removed.push(`hooks.${event}`);
@@ -548,12 +554,51 @@ export function uninstallAll({ purge = false } = {}) {
548
554
  return { ...result, action: 'removed' };
549
555
  }
550
556
 
557
+ // Older versions registered the cache-monitor hook as a file copied to
558
+ // ~/.claude/cache-monitor-hook.cjs. That copy never included
559
+ // harness-analyzer.cjs, went stale across upgrades, and escaped uninstall.
560
+ // Rewrite any such entry to the CLI subcommand form and delete the copy.
561
+ // Runs on every install (postinstall included) so upgrades self-heal.
562
+ export function migrateLegacyCacheMonitorHook() {
563
+ const dir = claudeUserDir();
564
+ const file = join(dir, 'settings.json');
565
+ if (!existsSync(file)) return { path: file, action: 'none' };
566
+ let settings;
567
+ try {
568
+ settings = JSON.parse(readFileSync(file, 'utf8'));
569
+ } catch (e) {
570
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
571
+ }
572
+ const list = settings.hooks?.PostToolUse;
573
+ if (!Array.isArray(list)) return { path: file, action: 'none' };
574
+
575
+ let migrated = false;
576
+ for (const m of list) {
577
+ if (!Array.isArray(m?.hooks)) continue;
578
+ for (const h of m.hooks) {
579
+ if (typeof h?.command !== 'string' || !h.command.includes('cache-monitor-hook')) continue;
580
+ const th = h.command.match(/--threshold\s+([\d.]+)/);
581
+ h.command = `claude-token-saver --hook-run --threshold ${th ? th[1] : '0.7'}`;
582
+ migrated = true;
583
+ }
584
+ }
585
+ if (migrated) writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
586
+
587
+ const legacyCopy = join(dir, 'cache-monitor-hook.cjs');
588
+ if (existsSync(legacyCopy)) {
589
+ rmSync(legacyCopy, { force: true });
590
+ migrated = true;
591
+ }
592
+ return { path: file, action: migrated ? 'migrated' : 'none' };
593
+ }
594
+
551
595
  export function installAll({ force = false } = {}) {
552
596
  return {
553
597
  skill: installSkill({ force }),
554
598
  statusline: installStatusline({ force }),
555
599
  sessionStartHook: installSessionStartHook(),
556
600
  briefHook: installBriefHook(),
601
+ cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
557
602
  legacy: removeLegacyCommand(),
558
603
  };
559
604
  }
@@ -48,6 +48,23 @@ export const HEALTH_MIN_SAMPLE = 10;
48
48
  // that merely resemble the rule's shape.
49
49
  export const HEALTH_MIN_SAMPLE_DELEGATED = 5;
50
50
 
51
+ /**
52
+ * Wilson 95% lower bound for an observed error proportion. A raw rate over a
53
+ * tiny sample flips healthy rules to review (2/8 = 25% has a 95% interval of
54
+ * [7%, 59%] β€” it does NOT establish the rate exceeds 20%). Only flag when the
55
+ * evidence actually clears the threshold.
56
+ */
57
+ export function wilsonLowerBound(errs, n) {
58
+ if (!n) return 0;
59
+ const z = 1.96;
60
+ const p = errs / n;
61
+ const z2 = z * z;
62
+ const denom = 1 + z2 / n;
63
+ const center = p + z2 / (2 * n);
64
+ const margin = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n);
65
+ return Math.max(0, (center - margin) / denom);
66
+ }
67
+
51
68
  // Fallback budget for rules promoted before budgets were recorded β€” matches
52
69
  // route-scan's pre-calibration defaults. Kept as local constants rather than
53
70
  // imported: route-scan imports this module, and a static back-import would
@@ -402,10 +419,12 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
402
419
  // catch a rule that is mis-firing on requests it should never have taken.
403
420
  if (r.delegatedRuns >= HEALTH_MIN_SAMPLE_DELEGATED) {
404
421
  r.healthSource = 'delegated';
405
- r.status = r.delegatedErrRate > HEALTH_ERR_RATE ? 'review' : 'active';
422
+ r.status = wilsonLowerBound(d ? d.errRuns : 0, r.delegatedRuns) > HEALTH_ERR_RATE
423
+ ? 'review' : 'active';
406
424
  } else {
407
425
  r.healthSource = 'proxy';
408
- r.status = s && r.errRate > HEALTH_ERR_RATE && s.epCount >= HEALTH_MIN_SAMPLE
426
+ r.status = s && s.epCount >= HEALTH_MIN_SAMPLE
427
+ && wilsonLowerBound(s.errCount || Math.round((r.errRate || 0) * s.epCount), s.epCount) > HEALTH_ERR_RATE
409
428
  ? 'review' : 'active';
410
429
  }
411
430
  changed = true;
package/src/parser.js CHANGED
@@ -72,6 +72,10 @@ export async function parseSessionFile(filePath) {
72
72
 
73
73
  requests.set(reqId, {
74
74
  requestId: reqId,
75
+ // Per-request timestamp so callers can filter by time window β€” the
76
+ // mtime-based file filter alone lets a long-lived session drag
77
+ // months-old requests into a narrow --days window.
78
+ ts: ts ? Date.parse(ts) : null,
75
79
  model: resolveModelAlias(msg.model),
76
80
  inputTokens: usage.input_tokens || 0,
77
81
  cacheCreationTokens: usage.cache_creation_input_tokens || 0,
@@ -84,6 +88,22 @@ export async function parseSessionFile(filePath) {
84
88
 
85
89
  const reqs = [...requests.values()];
86
90
  let maxContextPerRequest = 0;
91
+ // Representative model: the one that handled the most requests, skipping
92
+ // "<synthetic>" (Claude Code's local error-stub placeholder β€” no real API
93
+ // call). Taking reqs[0] blindly let an error stub at session start
94
+ // misclassify the whole session (route-scan.js already counts for the same
95
+ // reason). Falls back to '<synthetic>' only when nothing else exists.
96
+ const modelCounts = new Map();
97
+ for (const r of reqs) {
98
+ if (r.model === '<synthetic>') continue;
99
+ modelCounts.set(r.model, (modelCounts.get(r.model) || 0) + 1);
100
+ }
101
+ let model = 'unknown';
102
+ let best = 0;
103
+ for (const [m, n] of modelCounts) {
104
+ if (n > best) { best = n; model = m; }
105
+ }
106
+ if (best === 0 && reqs.length > 0) model = reqs[0].model || 'unknown';
87
107
  const totals = reqs.reduce(
88
108
  (acc, r) => {
89
109
  acc.input += r.inputTokens;
@@ -108,7 +128,7 @@ export async function parseSessionFile(filePath) {
108
128
  requests: reqs,
109
129
  totals,
110
130
  maxContextPerRequest,
111
- model: reqs[0]?.model || 'unknown',
131
+ model,
112
132
  gatewayObserved,
113
133
  };
114
134
  }
@@ -209,6 +229,52 @@ export async function discoverSessionFiles(options = {}) {
209
229
  * @param {object} [options] forwarded to discoverSessionFiles
210
230
  * @param {boolean} [options.noCache=false] bypass the cache entirely
211
231
  */
232
+ function spansCutoff(session, cutoffMs) {
233
+ return !!(session.startTime && session.startTime.getTime() < cutoffMs);
234
+ }
235
+
236
+ /**
237
+ * Re-aggregate a fully parsed session keeping only requests at or after the
238
+ * cutoff. Requests without a timestamp are kept β€” dropping data on a missing
239
+ * field would be worse than slight over-counting.
240
+ */
241
+ function trimToCutoff(session, cutoffMs) {
242
+ const reqs = (session.requests || []).filter((r) => r.ts == null || r.ts >= cutoffMs);
243
+ let maxContextPerRequest = 0;
244
+ let firstTs = null;
245
+ let lastTs = null;
246
+ const totals = reqs.reduce(
247
+ (acc, r) => {
248
+ acc.input += r.inputTokens;
249
+ acc.cacheCreation += r.cacheCreationTokens;
250
+ acc.cacheRead += r.cacheReadTokens;
251
+ acc.ephemeral5m += r.ephemeral5mTokens;
252
+ acc.ephemeral1h += r.ephemeral1hTokens;
253
+ acc.output += r.outputTokens;
254
+ const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
255
+ if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
256
+ if (r.ts != null) {
257
+ if (firstTs === null || r.ts < firstTs) firstTs = r.ts;
258
+ if (lastTs === null || r.ts > lastTs) lastTs = r.ts;
259
+ }
260
+ return acc;
261
+ },
262
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
263
+ );
264
+ return {
265
+ sessionId: session.sessionId,
266
+ filePath: session.filePath,
267
+ projectDir: session.projectDir,
268
+ startTime: firstTs !== null ? new Date(firstTs) : session.startTime,
269
+ endTime: lastTs !== null ? new Date(lastTs) : session.endTime,
270
+ requestCount: reqs.length,
271
+ totals,
272
+ maxContextPerRequest,
273
+ model: session.model,
274
+ gatewayObserved: session.gatewayObserved,
275
+ };
276
+ }
277
+
212
278
  export async function parseAllSessions(options = {}) {
213
279
  const files = await discoverSessionFiles(options);
214
280
  const concurrency = 10;
@@ -216,23 +282,39 @@ export async function parseAllSessions(options = {}) {
216
282
  const useCache = !options.noCache;
217
283
  const cache = useCache ? loadCache() : { entries: {} };
218
284
  let misses = 0;
285
+ // The file-level mtime filter (discoverSessionFiles) is only a cheap
286
+ // pre-selection: a session started months ago but touched today passes it,
287
+ // and its old requests would pollute every aggregate in the window. Any
288
+ // session whose startTime precedes the cutoff is re-aggregated from its
289
+ // per-request timestamps. The trimmed summary is NOT cached β€” the cache
290
+ // stores the window-independent full parse.
291
+ const days = options.days ?? 30;
292
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
219
293
 
220
294
  for (let i = 0; i < files.length; i += concurrency) {
221
295
  const batch = files.slice(i, i + concurrency);
222
296
  const parsed = await Promise.all(
223
297
  batch.map(async (f) => {
224
- if (useCache) {
225
- const hit = getCached(cache, f);
226
- if (hit) return hit;
227
- }
228
298
  try {
299
+ if (useCache) {
300
+ const hit = getCached(cache, f);
301
+ if (hit) {
302
+ if (!spansCutoff(hit, cutoffMs)) return hit;
303
+ // Boundary session from cache: the cached summary has no
304
+ // per-request data, so re-read the file to trim it.
305
+ const full = await parseSessionFile(f.path);
306
+ full.projectDir = f.projectDir;
307
+ return trimToCutoff(full, cutoffMs);
308
+ }
309
+ }
229
310
  const session = await parseSessionFile(f.path);
230
311
  session.projectDir = f.projectDir;
231
- const { requests, ...summary } = session;
232
312
  if (useCache) {
233
313
  putCached(cache, f, session);
234
314
  misses++;
235
315
  }
316
+ if (spansCutoff(session, cutoffMs)) return trimToCutoff(session, cutoffMs);
317
+ const { requests, ...summary } = session;
236
318
  return summary;
237
319
  } catch {
238
320
  return null;
@@ -29,7 +29,9 @@ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
29
29
  // 2: sessions carry `gatewayObserved`. Entries written by version 1 lack it,
30
30
  // and a missing flag reads as "not a gateway" β€” the wrong default for exactly
31
31
  // the users the flag exists for.
32
- const CACHE_VERSION = 2;
32
+ // 3: version 2's serialize() never actually wrote `gatewayObserved`, so every
33
+ // v2 entry lacks the flag it was bumped for. Bumped again to discard them.
34
+ const CACHE_VERSION = 3;
33
35
  // Entries for transcripts this old are pruned on write. Keeps the file
34
36
  // bounded without an existence check per entry (which would cost the syscalls
35
37
  // the cache exists to avoid).
@@ -58,6 +60,7 @@ function serialize(session) {
58
60
  totals: session.totals,
59
61
  maxContextPerRequest: session.maxContextPerRequest,
60
62
  model: session.model,
63
+ gatewayObserved: !!session.gatewayObserved,
61
64
  };
62
65
  }
63
66
 
@@ -72,6 +75,7 @@ function deserialize(stored, filePath, projectDir) {
72
75
  totals: stored.totals,
73
76
  maxContextPerRequest: stored.maxContextPerRequest || 0,
74
77
  model: stored.model || 'unknown',
78
+ gatewayObserved: !!stored.gatewayObserved,
75
79
  };
76
80
  }
77
81
 
@@ -53,7 +53,18 @@ const REJECTION_RE = /doesn't want to proceed|tool use was rejected|doesn't want
53
53
  // yet" was 7/65 real errors in one project, edit-races + Task-lifecycle several
54
54
  // more). Kept NARROW on purpose: ambiguous shell failures ("Exit code N", "File
55
55
  // does not exist" on a Read) stay counted β€” those are genuine difficulty signal.
56
- const SELF_CORRECTED_RE = /File has not been read yet|has been modified since read|String to replace not found|is not running \(status:|<tool_use_error>Blocked:/i;
56
+ // Tool-argument schema violations (InputValidationError, "does not match the
57
+ // required schema/pattern") are the model mis-building a call, corrected on
58
+ // the next attempt β€” same self-correction family, not task difficulty.
59
+ // Measured 2026-09-09: they were the single largest numerator item (10/125).
60
+ const SELF_CORRECTED_RE = /File has not been read yet|has been modified since read|String to replace not found|is not running \(status:|<tool_use_error>Blocked:|InputValidationError|does not match the required/i;
61
+
62
+ // Failures with no diagnosable content β€” a bare exit code or "no output" β€”
63
+ // carry no evidence about WHY they failed, so they cannot support a
64
+ // rule-health verdict either way. Excluded from the numerator (16% of it was
65
+ // this plus schema errors, against a 20% review threshold). Anchored: an
66
+ // "Exit code 1" followed by a traceback still counts.
67
+ const NO_SIGNAL_RE = /^\s*(Command failed with no output|Exit code \d+)\s*$/;
57
68
 
58
69
  // Environment constraints β€” a sandbox without `curl`/`wc`, a corporate proxy
59
70
  // timing a fetch out β€” are not task difficulty either. The agent routinely
@@ -72,7 +83,8 @@ function toolResultText(content) {
72
83
  function isRealToolError(block) {
73
84
  if (!block || block.type !== 'tool_result' || !block.is_error) return false;
74
85
  const txt = toolResultText(block.content);
75
- return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt) && !ENVIRONMENT_RE.test(txt);
86
+ return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt)
87
+ && !ENVIRONMENT_RE.test(txt) && !NO_SIGNAL_RE.test(txt);
76
88
  }
77
89
 
78
90
  export async function collectSessionRecords(filePath, { includeContent = true } = {}) {
@@ -35,13 +35,29 @@ export function readStdinJson() {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * A percentage from the payload, or null when the field is absent, empty, or
40
+ * non-numeric. `Number(null|''|[])` is 0, so a plain Number() call renders
41
+ * missing data as "0% used" β€” the most dangerously wrong reading possible for
42
+ * a cap gauge. Out-of-range values are clamped to 0..100 at this entry point
43
+ * so every renderer (and caps-cache.js, which persists the value) agrees.
44
+ */
45
+ function normalizePct(raw) {
46
+ if (typeof raw !== 'number') {
47
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
48
+ raw = Number(raw);
49
+ }
50
+ if (!Number.isFinite(raw)) return null;
51
+ return Math.max(0, Math.min(100, raw));
52
+ }
53
+
38
54
  export function extractCaps(stdinJson) {
39
55
  if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
40
56
  const windows = [];
41
57
  for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
42
58
  if (!value || typeof value !== 'object') continue;
43
- const usedPct = Number(value.used_percentage);
44
- if (!Number.isFinite(usedPct)) continue;
59
+ const usedPct = normalizePct(value.used_percentage);
60
+ if (usedPct === null) continue;
45
61
  const resetsAt = Number(value.resets_at);
46
62
  windows.push({
47
63
  key,
@@ -82,9 +98,9 @@ export function bedrockDisplayFromId(id) {
82
98
  export function extractContextUsage(stdinJson) {
83
99
  const cw = stdinJson && stdinJson.context_window;
84
100
  if (!cw || typeof cw !== 'object') return null;
85
- const usedPct = Number(cw.used_percentage);
101
+ const usedPct = normalizePct(cw.used_percentage);
86
102
  const size = Number(cw.context_window_size);
87
- if (!Number.isFinite(usedPct)) return null;
103
+ if (usedPct === null) return null;
88
104
  return {
89
105
  usedPct,
90
106
  size: Number.isFinite(size) && size > 0 ? size : null,