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 +3 -0
- package/README.md +3 -0
- package/bin/cli.js +33 -0
- package/package.json +1 -1
- package/src/brief.js +1 -1
- package/src/cost.js +6 -1
- package/src/formatters/statusline.js +5 -1
- package/src/harness-analyzer.cjs +9 -4
- package/src/hook-manager.js +31 -24
- package/src/hook.cjs +23 -11
- package/src/installer.js +49 -4
- package/src/model-rules.js +21 -2
- package/src/parser.js +88 -6
- package/src/session-cache.js +5 -1
- package/src/session-records.js +14 -2
- package/src/stdin-payload.js +20 -4
package/README.en.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](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
|
[](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.
|
|
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
|
-
|
|
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
|
-
|
|
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)}`;
|
package/src/harness-analyzer.cjs
CHANGED
|
@@ -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.
|
|
44
|
-
return path.join(process.env.
|
|
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
|
-
|
|
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) {
|
package/src/hook-manager.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install/uninstall the
|
|
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,
|
|
6
|
-
import { join
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
154
|
-
// - statusLine already points at claude-token-saver: ensure refreshInterval
|
|
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:
|
|
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'
|
|
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
|
}
|
package/src/model-rules.js
CHANGED
|
@@ -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.
|
|
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 &&
|
|
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
|
|
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;
|
package/src/session-cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/session-records.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 } = {}) {
|
package/src/stdin-payload.js
CHANGED
|
@@ -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 =
|
|
44
|
-
if (
|
|
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 =
|
|
101
|
+
const usedPct = normalizePct(cw.used_percentage);
|
|
86
102
|
const size = Number(cw.context_window_size);
|
|
87
|
-
if (
|
|
103
|
+
if (usedPct === null) return null;
|
|
88
104
|
return {
|
|
89
105
|
usedPct,
|
|
90
106
|
size: Number.isFinite(size) && size > 0 ? size : null,
|