feat: /caveman-stats — real session token usage + savings estimate

New slash command that reads the active Claude Code session JSONL
(.claude/projects/**/*.jsonl), sums output_tokens and
cache_read_input_tokens from assistant turns, and shows an estimated
savings figure when the active mode is 'full'.

Real numbers, not the model's guess:

  Caveman Stats
  ──────────────────────────────────
  Session:  ...projects/my-app/abc123.jsonl
  Turns:    47
  ──────────────────────────────────
  Output tokens:         3,210
  Cache-read tokens:     128,400
  ──────────────────────────────────
  Est. without caveman:  9,171
  Est. tokens saved:     5,961 (~65%)
  Savings est. from benchmarks/ (mean per-task). Actual varies by task.

Implementation:
* hooks/caveman-stats.js — script. Run directly with
  `node hooks/caveman-stats.js`, or via `--session-file <path>`.
  Falls back to most-recent JSONL under .claude/projects/ when no
  session file is passed.
* hooks/caveman-mode-tracker.js — `/caveman-stats` triggers an
  execFileSync call to caveman-stats.js with the hook's transcript_path,
  and the output is returned via `decision: "block"` so the user sees
  the stats inline without a model round-trip.
* install.sh / install.ps1 / uninstall.{sh,ps1} include
  caveman-stats.js in HOOK_FILES.
* skills/caveman-stats/SKILL.md (+ plugin mirror) for skill listing.
* README install matrix and Caveman Skills section updated.

Compression ratio (0.65) is the mean per-task figure from
benchmarks/results/*.json (avg_savings: 65 across 10 tasks). Only 'full'
mode has measured data — lite/ultra/wenyan show no estimate.

Tests: 6 passing in tests/test_caveman_stats.js covering direct
invocation, full-mode estimate math, non-full skip, no-session error,
mode-tracker block behavior, and flag preservation.

Closes #305 (re-implementation; takes the design from
@DeeptimaanB but rewritten against current main).

Co-Authored-By: Deeptimaan Banerjee <DeeptimaanB@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-05-01 01:14:53 +02:00
co-authored by Deeptimaan Banerjee Claude Opus 4.7
parent a9dc067796
commit 31fa95478d
10 changed files with 310 additions and 4 deletions
+22
View File
@@ -159,6 +159,7 @@ Auto-activation is built in for Claude Code, Gemini CLI, and the repo-local Code
| caveman-review | Y | — | Y | Y | Y | Y | Y |
| caveman-compress | Y | Y | Y | Y | Y | Y | Y |
| caveman-help | Y | — | Y | Y | Y | Y | Y |
| caveman-stats | Y | — | — | — | — | — | — |
> [!NOTE]
> Auto-activation works differently per agent: Claude Code uses SessionStart hooks, this repo's Codex dogfood setup uses `.codex/hooks.json`, Gemini uses context files. Cursor/Windsurf/Cline/Copilot can be made always-on, but `npx skills add` installs only the skill, not the repo rule/instruction files.
@@ -264,6 +265,7 @@ Auto-activates via `GEMINI.md` context file. Also ships custom Gemini commands:
- `/caveman` — switch intensity level (lite/full/ultra/wenyan)
- `/caveman-commit` — generate terse commit message
- `/caveman-review` — one-line code review
- `/caveman-stats` — real token usage + estimated savings (reads session log)
</details>
@@ -371,6 +373,26 @@ Level stick until you change it or session end.
`/caveman-help` — quick-reference card. All modes, skills, commands, one command away.
### caveman-stats
`/caveman-stats` — real token usage for current session + estimated savings. Reads the Claude Code session JSONL directly so the numbers are not the model's guess. Savings estimate uses the 65% mean per-task figure from `benchmarks/`; only `full` mode has measured data.
```
Caveman Stats
──────────────────────────────────
Session: ...projects/my-app/abc123.jsonl
Turns: 47
──────────────────────────────────
Output tokens: 3,210
Cache-read tokens: 128,400
──────────────────────────────────
Est. without caveman: 9,171
Est. tokens saved: 5,961 (~65%)
Savings est. from benchmarks/ (mean per-task). Actual varies by task.
```
Claude Code only — needs the hook system to read the session transcript.
### caveman-compress
`/caveman:compress <filepath>` — caveman make Claude *speak* with fewer tokens. **Compress** make Claude *read* fewer tokens.
+20
View File
@@ -5,6 +5,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = require('./caveman-config');
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
@@ -34,6 +35,25 @@ process.stdin.on('end', () => {
}
}
// /caveman-stats — block the prompt and inject stats output as the
// hook's reason. The script reads the active session log, so we pass
// transcript_path through when Claude Code provides it.
if (prompt === '/caveman-stats' || prompt === '/caveman:caveman-stats') {
try {
const statsPath = path.join(__dirname, 'caveman-stats.js');
const argv = [statsPath];
if (data.transcript_path) argv.push('--session-file', data.transcript_path);
const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 });
process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() }));
} catch (e) {
process.stdout.write(JSON.stringify({
decision: 'block',
reason: 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js'
}));
}
return;
}
// Match /caveman commands
if (prompt.startsWith('/caveman')) {
const parts = prompt.split(/\s+/);
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
// caveman-stats — read the active Claude Code session log, print real token
// usage plus an estimated savings figure from the benchmark in benchmarks/.
//
// Run directly: node hooks/caveman-stats.js
// Inside Claude: /caveman-stats triggers this via the UserPromptSubmit hook.
// Hook integration passes --session-file <transcript_path> so we always read
// the active session, not whichever JSONL was modified most recently.
const fs = require('fs');
const path = require('path');
const os = require('os');
const { readFlag } = require('./caveman-config');
// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite/ultra/
// wenyan modes show no estimate.
const COMPRESSION = { 'full': 0.65 };
function findRecentSession(claudeDir) {
const projectsDir = path.join(claudeDir, 'projects');
let entries;
try { entries = fs.readdirSync(projectsDir, { withFileTypes: true }); }
catch { return null; }
let best = null;
const stack = entries.map(e => path.join(projectsDir, e.name));
while (stack.length) {
const p = stack.pop();
let st;
try { st = fs.statSync(p); } catch { continue; }
if (st.isDirectory()) {
try {
for (const child of fs.readdirSync(p)) stack.push(path.join(p, child));
} catch {}
} else if (p.endsWith('.jsonl') && (!best || st.mtimeMs > best.mtime)) {
best = { file: p, mtime: st.mtimeMs };
}
}
return best ? best.file : null;
}
function parseSession(filePath) {
let raw;
try { raw = fs.readFileSync(filePath, 'utf8'); }
catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0 }; }
let outputTokens = 0;
let cacheReadTokens = 0;
let turns = 0;
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
const usage = entry.type === 'assistant' && entry.message && entry.message.usage;
if (!usage) continue;
outputTokens += usage.output_tokens || 0;
cacheReadTokens += usage.cache_read_input_tokens || 0;
turns++;
}
return { outputTokens, cacheReadTokens, turns };
}
function main() {
const args = process.argv.slice(2);
const i = args.indexOf('--session-file');
const sessionFileArg = i !== -1 ? args[i + 1] : null;
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const sessionFile = sessionFileArg || findRecentSession(claudeDir);
if (!sessionFile) {
process.stderr.write('caveman-stats: no Claude Code session found.\n');
process.exit(1);
}
const { outputTokens, cacheReadTokens, turns } = parseSession(sessionFile);
const mode = readFlag(path.join(claudeDir, '.caveman-active'));
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
const sep = '──────────────────────────────────';
const shortPath = sessionFile.length > 45 ? '...' + sessionFile.slice(-45) : sessionFile;
if (turns === 0) {
process.stdout.write(`\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`);
return;
}
let savings;
let footer = '';
if (ratio !== null) {
const estNormal = Math.round(outputTokens / (1 - ratio));
const estSaved = estNormal - outputTokens;
savings = `Est. without caveman: ${estNormal.toLocaleString()}\n` +
`Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}%)`;
footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.';
} else if (mode && mode !== 'off') {
savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`;
} else {
savings = 'Caveman not active this session.';
}
process.stdout.write(
`\nCaveman Stats\n${sep}\n` +
`Session: ${shortPath}\n` +
`Turns: ${turns}\n${sep}\n` +
`Output tokens: ${outputTokens.toLocaleString()}\n` +
`Cache-read tokens: ${cacheReadTokens.toLocaleString()}\n${sep}\n` +
`${savings}\n` +
(footer ? footer + '\n' : '')
);
}
main();
+1 -1
View File
@@ -24,7 +24,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
$Settings = Join-Path $ClaudeDir "settings.json"
$RepoUrl = "https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh", "caveman-statusline.ps1")
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
# Resolve source — works from repo clone or remote
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
+1 -1
View File
@@ -37,7 +37,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
SETTINGS="$CLAUDE_DIR/settings.json"
REPO_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-statusline.sh")
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
# Resolve source — works from repo clone or curl pipe
SCRIPT_DIR=""
+1 -1
View File
@@ -11,7 +11,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
$Settings = Join-Path $ClaudeDir "settings.json"
$FlagFile = Join-Path $ClaudeDir ".caveman-active"
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh", "caveman-statusline.ps1")
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
# Detect if caveman is installed as a plugin
$PluginInstalled = $false
+1 -1
View File
@@ -10,7 +10,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
SETTINGS="$CLAUDE_DIR/settings.json"
FLAG_FILE="$CLAUDE_DIR/.caveman-active"
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-statusline.sh")
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
# Detect if caveman is installed as a plugin (check plugin cache)
PLUGIN_INSTALLED=0
@@ -0,0 +1,10 @@
---
name: caveman-stats
description: >
Show real token usage and estimated savings for the current session.
Reads directly from the Claude Code session log — no AI estimation.
Triggers on /caveman-stats. Output is injected by the mode-tracker hook;
the model itself does not compute the numbers.
---
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
+10
View File
@@ -0,0 +1,10 @@
---
name: caveman-stats
description: >
Show real token usage and estimated savings for the current session.
Reads directly from the Claude Code session log — no AI estimation.
Triggers on /caveman-stats. Output is injected by the mode-tracker hook;
the model itself does not compute the numbers.
---
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Tests for /caveman-stats — direct script invocation and via mode tracker.
// Run: node tests/test_caveman_stats.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const STATS = path.join(ROOT, 'hooks', 'caveman-stats.js');
const TRACKER = path.join(ROOT, 'hooks', 'caveman-mode-tracker.js');
let passed = 0;
let failed = 0;
function test(name, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-stats-test-'));
try {
fn(tmp);
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.error(`${name}\n ${e.message}`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
function makeSession(dir, lines) {
const projDir = path.join(dir, '.claude', 'projects', 'p');
fs.mkdirSync(projDir, { recursive: true });
const sessFile = path.join(projDir, 's.jsonl');
fs.writeFileSync(sessFile, lines.map(l => JSON.stringify(l)).join('\n'));
return sessFile;
}
console.log('caveman-stats tests\n');
test('reads --session-file directly and sums output tokens', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 100, cache_read_input_tokens: 200 } } },
{ type: 'user', message: { content: 'hi' } },
{ type: 'assistant', message: { usage: { output_tokens: 50, cache_read_input_tokens: 50 } } },
]);
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: path.join(tmp, '.claude') },
});
assert.match(out, /Turns:\s+2/);
assert.match(out, /Output tokens:\s+150/);
assert.match(out, /Cache-read tokens:\s+250/);
});
test('shows full-mode savings estimate when flag is full', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 350 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
// 350 / 0.35 = 1000, saved = 650, ~65%
assert.match(out, /Est\. without caveman:\s+1,000/);
assert.match(out, /Est\. tokens saved:\s+650 \(~65%\)/);
});
test('skips estimate for non-full modes', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 100 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'ultra');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
assert.match(out, /No savings estimate for 'ultra' mode/);
});
test('reports no-session when no .jsonl exists', (tmp) => {
fs.mkdirSync(path.join(tmp, '.claude', 'projects'), { recursive: true });
let err = null;
try {
execFileSync(process.execPath, [STATS], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: path.join(tmp, '.claude') },
});
} catch (e) { err = e; }
assert.ok(err, 'should exit non-zero');
assert.match(err.stderr, /no Claude Code session found/);
});
test('mode tracker handles /caveman-stats with decision block', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 100 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
const out = execFileSync(process.execPath, [TRACKER], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp },
input: JSON.stringify({ prompt: '/caveman-stats', transcript_path: sess }),
});
const parsed = JSON.parse(out);
assert.strictEqual(parsed.decision, 'block');
assert.match(parsed.reason, /Caveman Stats/);
assert.match(parsed.reason, /Output tokens:\s+100/);
});
test('mode tracker preserves caveman flag when /caveman-stats fires', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 50 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
execFileSync(process.execPath, [TRACKER], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp },
input: JSON.stringify({ prompt: '/caveman-stats', transcript_path: sess }),
});
// The flag must still say 'full' — the stats command must not change mode.
assert.strictEqual(fs.readFileSync(path.join(claudeDir, '.caveman-active'), 'utf8'), 'full');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);