Context-size meter, noisy tool-output pruning and a cost audit for Claude Code, using OpenAI Decisions (gpt-6-luna).

luna-pruner is a Claude Code plugin with four parts, ordered by how much they matter in real transcripts:
/compact or /clear (Luna decides which, from whether your new prompt switches topic). Long histories re-read every turn were ~80% of the cost in the transcripts I measured./luna-pruner:luna-stats): spent on Luna vs saved, in tokens, USD and BRL, from the plugin's own event log.node scripts/audit.mjs): 100% local report of where your Claude Code spend goes: context size, fixed prefix, injected hooks/skills/MCP names, biggest tool outputs.gpt-6-luna) scores relevance, plain code cuts, a marker points to the untouched original. Valuable for log-heavy work, small on data/code-heavy sessions.Read docs/findings.md for the numbers, including what did not work and how this combines with RTK and Caveman.
A Bash command printing 1,600 lines (800 × npm WARN deprecated ..., one real error line, 800 × progress N/800), as Claude sees it:
npm WARN deprecated pkg1@1.0.1: no longer supported
(… +799 linhas similares)
auth.ts: session.user is undefined after login redirect
progress 1/800 downloading
(… +799 linhas similares)
Synthetic noisy logs: 67,731 chars → 190 chars. On 3 real sessions only 6 of 63 large outputs were prunable (2.6% of tool-output chars), because most were genuinely relevant. See docs/findings.md.
/plugin marketplace add luancamara/luna-pruner
/plugin install luna-pruner@luna-pruner
Requirements: Node 18+ and OPENAI_API_KEY in the environment Claude Code starts from. To try it without installing: claude --plugin-dir ./luna-pruner.
Disable with LUNA_PRUNER_OFF=1. Cost audit: node scripts/audit.mjs [sessions] [projects-dir]. Stats: /luna-pruner:luna-stats [--days N] [--brl 5.40] or node scripts/stats.mjs. Logs and untouched originals are in ${CLAUDE_PLUGIN_DATA} (luna.log, raw/).
OpenAI Decisions returns typed answers (scores and probabilities), not text. So Luna decides and code cuts. Details in docs/how-it-works.md.
Bash, WebFetch, Grep, mcp__*): outputs over ~6k chars are collapsed, split into 20-line chunks, and each chunk is scored 0-3 for relevance to your latest request. Low-scoring chunks are replaced by a marker pointing at the full original on disk. Returned to Claude via updatedToolOutput.usage fields; above LUNA_PRUNER_WARN_TOKENS (default 150000) it shows a one-line hint to you (not added to Claude's context), at most once per +75k tokens.Cost: Decisions input is $0.10 per 1M tokens with no output charge, so a typical session costs fractions of a cent.
How do I reduce token usage in Claude Code? Stop large tool outputs from entering the context. luna-pruner does this automatically with a PostToolUse hook; /compact and /clear handle old conversation.
Can a Claude Code hook delete old messages from the context? No. Hooks can replace a tool's output before Claude sees it (updatedToolOutput) and add context, but they cannot edit history or trigger /compact / /clear. This plugin prunes new tool output and suggests compaction.
What is the OpenAI Decisions API? POST /v1/decisions: fast classification/scoring with typed answers (predicate, choice, score). Only gpt-6-luna is available (public beta at time of writing). It does not generate text, so it cannot summarize.
Is it safe? Does it send my data to OpenAI? Yes, tool output over the size threshold is sent to OpenAI for scoring. Output that looks like secrets (.env, credentials, private keys, token=..., sk-...) is never sent. See SECURITY.md.
What if Luna prunes something important? The first and last chunks and any chunk containing error, exception, fail, stack traces are always kept, and the marker names the untouched original so Claude can read it.
What happens if the API is down or the key is missing? Fail-open: the output passes through unchanged.
Does it work with other agents (Cursor, Codex, Gemini CLI)? Not today. It targets Claude Code's hook protocol. Ports are welcome, see CONTRIBUTING.md.
/luna-stats # native colored panel drawn inside the conversation (mod API, no model turn)
/luna-stats --days 7 # last 7 days; --brl 5.40 to set the exchange rate
luna-stats # same dashboard in a plain terminal (colors auto-detected)
/luna-pruner:luna-stats # plain-text fallback through the model (costs one turn)
/luna-stats uses Claude Code's experimental plugin "mod" API (modules in hooks/hooks.json, tested on Claude Code 2.1.295). If your build lacks it, the shell and fallback commands above still work.
Synthetic example (fixture data, not real usage):
╭──────────────────────────────────────────────────────────────────────────────╮
│ ◆ luna-pruner · gasto × economia · todo o período │
╰──────────────────────────────────────────────────────────────────────────────╯
SALDO LÍQUIDO
US$ 0,0485 ≈ R$ 0,24 ▲ 486× o que gastou
╭─ Economia ─────────────╮ ╭─ Gasto Luna ───────────╮ ╭─ Podadas ──────────────╮
│ 9,0 mil tok │ │ 1,0 mil tok │ │ 1 de 1 │
│ US$ 0,0486 │ │ US$ 0,0001 │ │ 100% das grandes │
│ +18,0 mil releit. │ │ 1 chamada │ │ 1 aviso de ctx │
╰────────────────────────╯ ╰────────────────────────╯ ╰────────────────────────╯
ECONOMIA POR FERRAMENTA
Bash ██████████████████████████████████ 9,0 mil US$ 0,0486 1×
POR DIA █ economia
10-09 ████████████████████████████████████████ US$ 0,0486 −US$ 0,0001
3.6 chars/token (estimativa)
preços de terceiros: confira scripts/prices.json
cache escrito 1x + relido a cada turno até compactar
câmbio 5.00 (informado)
Every Luna call and every large output is logged to ${CLAUDE_PLUGIN_DATA}/events.jsonl (sizes and counts only, never content). scripts/stats.mjs joins that with your transcripts: tokens removed are counted once as a cache write plus once per following turn as a cache read, until the next compaction, priced from scripts/prices.json. Luna spend counts every call, including those that pruned nothing. USD to BRL uses a live rate (or --brl). The prices in prices.json come from third-party sites; check them against Anthropic's pricing page and edit. The context-size hints are not counted as savings, since that depends on you compacting.
Read is excluded on purpose (pruning can break edits).Issues and PRs are welcome: bug reports with a luna.log excerpt, unhandled tool-output shapes, better chunking, an evaluation set, ports to other agents. Start with CONTRIBUTING.md.
MIT. See LICENSE. If you use this in research or writing, cite via CITATION.cff.
hooks/stats-ui.tsx 144 lines1// Painel nativo do /luna-stats: o comando roda scripts/stats.mjs --json e o hook de ui.render
2// desenha o resultado como árvore colorida dentro da conversa (sem gastar uma volta do modelo).
3import type { Register } from 'claude-code'
4
5const NAME = 'luna-stats'
6const MARK = '⟦luna-stats⟧'
7
8type Stats = {
9 days: number; calls: number; spentTok: number; spentUsd: number; nPrune: number; nSent: number
10 savedTok: number; rereads: number; savedUsd: number; warns: number; brl: number
11 byTool: Record<string, { n: number; tok: number; usd: number }>
12 byDay: Record<string, { saved: number; spent: number }>
13 notes: string[]
14}
15
16const GREEN = '#4ade80', RED = '#fb7163', CYAN = '#38bdf8', VIOLET = '#a78bfa', GRAY = '#94a3b8'
17
18const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(2).replace('.', ',')} mi` : n >= 1e3 ? `${(n / 1e3).toFixed(1).replace('.', ',')} mil` : String(Math.round(n)))
19const money = (x: number, cur: string) => `${cur} ${x.toLocaleString('pt-BR', { minimumFractionDigits: x < 0.1 ? 4 : 2, maximumFractionDigits: x < 0.1 ? 4 : 2 })}`
20const times = (x: number) => (x >= 100 ? Math.round(x).toLocaleString('pt-BR') : x.toFixed(1).replace('.', ',')) + '×'
21const mix = (a: number[], b: number[], k: number) => '#' + a.map((v, i) => Math.round(v + (b[i] - v) * k).toString(16).padStart(2, '0')).join('')
22
23export const parse = (text: string): Stats | null => {
24 const at = text.indexOf(MARK)
25 if (at < 0) return null
26 try { return JSON.parse(text.slice(at + MARK.length)) as Stats } catch { return null }
27}
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 await $.command.register({
32 name: NAME,
33 description: 'Painel de gasto × economia do luna-pruner (tokens, USD, R$)',
34 argumentHint: '[--days N] [--brl 5.40]',
35 })
36 return next(e)
37 })
38
39 on('command.run', { command: NAME }, async ($, e) => {
40 const args = e.args.split(/\s+/).filter(Boolean)
41 const run = await $.process.run(['node', `${$.plugin.root}/scripts/stats.mjs`, '--json', ...args], { timeoutMs: 15000 })
42 if (run.exitCode !== 0) return { text: `luna-stats falhou (${run.exitCode}): ${run.stderr.slice(0, 200)}` }
43 return { text: MARK + run.stdout.trim() }
44 }).catch(() => ({ text: 'luna-stats falhou' }))
45
46 on('ui.render', { component: 'CommandOutput', props: { command: NAME } }, ($, e, next) => {
47 const s = parse(e.props.text)
48 if (!s) return next(e)
49 const { Box, Text } = $.ui.resolve(e)
50 const cols = Math.max(60, Math.min((e.viewport?.columns ?? 80) - 4, 100))
51 const net = s.savedUsd - s.spentUsd
52 const fx = (usd: number) => (s.brl ? <Text dimColor> ≈ {money(usd * s.brl, 'R$')}</Text> : null)
53
54 const bar = (frac: number, cells: number, from = [74, 222, 128], to = [56, 189, 248]) => {
55 const full = Math.max(0, Math.min(cells, Math.round(frac * cells)))
56 return (
57 <Text>
58 {Array.from({ length: full }, (_, i) => (
59 <Text color={mix(from, to, cells > 1 ? i / (cells - 1) : 0)}>█</Text>
60 ))}
61 <Text dimColor>{'░'.repeat(cells - full)}</Text>
62 </Text>
63 )
64 }
65
66 const card = (title: string, color: string, big: string, l2: string, l3: string) => (
67 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={color} paddingX={1}>
68 <Text color={color} bold>{title}</Text>
69 <Text bold color={color}>{big}</Text>
70 <Text color={GRAY}>{l2}</Text>
71 <Text dimColor>{l3}</Text>
72 </Box>
73 )
74
75 const tools = Object.entries(s.byTool).sort((a, b) => b[1].usd - a[1].usd).slice(0, 6)
76 const toolMax = Math.max(...tools.map(([, v]) => v.usd), 1e-12)
77 const days = Object.entries(s.byDay).sort((a, b) => a[0].localeCompare(b[0])).slice(-7)
78 const dayMax = Math.max(...days.map(([, v]) => Math.max(v.saved, v.spent)), 1e-12)
79 const barW = Math.max(10, cols - 44)
80
81 if (!s.calls && !s.nSent) {
82 return (
83 <Box flexDirection="column" width={cols}>
84 <Box borderStyle="round" borderColor={CYAN} paddingX={1}><Text bold color={CYAN}>◆ luna-pruner</Text></Box>
85 <Text color="#fbbf24">Ainda sem eventos. <Text dimColor>Use o plugin numa sessão (saídas grandes de tools) e rode de novo.</Text></Text>
86 </Box>
87 )
88 }
89
90 return (
91 <Box flexDirection="column" width={cols}>
92 <Box borderStyle="round" borderColor={CYAN} paddingX={1}>
93 <Text bold color={CYAN}>◆ luna-pruner</Text>
94 <Text dimColor> · gasto × economia · </Text>
95 <Text color={GRAY}>{s.days ? `últimos ${s.days} dias` : 'todo o período'}</Text>
96 </Box>
97
98 <Box flexDirection="column" marginTop={1}>
99 <Text dimColor> SALDO LÍQUIDO</Text>
100 <Text>
101 {' '}<Text bold color={net >= 0 ? GREEN : RED}>{money(net, 'US$')}</Text>
102 {fx(net)}
103 {s.spentUsd > 0 ? <Text bold color={GREEN}>{' ▲ '}{times(s.savedUsd / s.spentUsd)}<Text dimColor> o que gastou</Text></Text> : null}
104 </Text>
105 </Box>
106
107 <Box marginTop={1} gap={1}>
108 {card('Economia', GREEN, `${tok(s.savedTok)} tok`, money(s.savedUsd, 'US$'), `+${tok(s.rereads)} releit.`)}
109 {card('Gasto Luna', RED, `${tok(s.spentTok)} tok`, money(s.spentUsd, 'US$'), `${s.calls} ${s.calls === 1 ? 'chamada' : 'chamadas'}`)}
110 {card('Podadas', VIOLET, `${s.nPrune} de ${s.nSent}`, s.nSent ? `${Math.round((100 * s.nPrune) / s.nSent)}% das grandes` : '—', `${s.warns} ${s.warns === 1 ? 'aviso' : 'avisos'} de ctx`)}
111 </Box>
112
113 {tools.length > 0 && (
114 <Box flexDirection="column" marginTop={1}>
115 <Text dimColor> ECONOMIA POR FERRAMENTA</Text>
116 {tools.map(([name, v]) => (
117 <Text>
118 {' '}<Text bold>{name.replace(/^mcp__/, 'mcp:').slice(0, 14).padEnd(14)}</Text>{' '}
119 {bar(v.usd / toolMax, barW)}{' '}
120 {tok(v.tok).padStart(8)}{' '}{money(v.usd, 'US$').padStart(11)} <Text dimColor>{`${v.n}×`.padStart(4)}</Text>
121 </Text>
122 ))}
123 </Box>
124 )}
125
126 {days.length > 0 && (
127 <Box flexDirection="column" marginTop={1}>
128 <Text dimColor> POR DIA</Text>
129 {days.map(([d, v]) => (
130 <Text>
131 {' '}<Text color={GRAY}>{d.slice(5)}</Text>{' '}{bar(v.saved / dayMax, barW)}{' '}
132 <Text color={GREEN}>{money(v.saved, 'US$').padStart(11)}</Text>{' '}
133 <Text color={RED}>{('−' + money(v.spent, 'US$')).padStart(12)}</Text>
134 </Text>
135 ))}
136 </Box>
137 )}
138
139 <Box marginTop={1}><Text dimColor wrap="wrap">{s.notes.join(' · ')}</Text></Box>
140 </Box>
141 )
142 })
143}
144