SLOPSHOPPER

luna-pruner

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

newrowscommandprocess
★ 1v0.4.0MITupdated 2026-10-09luancamara/luna-pruner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · luna-pruner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /luna-stats ⎿ luna-pruner: ⟦luna-stats⟧dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

luna-pruner: cut Claude Code token costs with a context-size meter, output pruning and a cost audit

luna-pruner is a Claude Code plugin with four parts, ordered by how much they matter in real transcripts:

  1. Context-size meter (UserPromptSubmit): reads the real context size and, past ~150k tokens, tells you to /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.
  2. Stats (/luna-pruner:luna-stats): spent on Luna vs saved, in tokens, USD and BRL, from the plugin's own event log.
  3. Cost audit (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.
  4. Output pruning (PostToolUse): keeps long, noisy tool output out of the context. OpenAI's Decisions API (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.

test license node deps

Output pruning: before / after

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.

Install

/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/).

How it works

OpenAI Decisions returns typed answers (scores and probabilities), not text. So Luna decides and code cuts. Details in docs/how-it-works.md.

  1. PostToolUse hook (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.
  2. UserPromptSubmit hook: reads the real context size from the transcript 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.

FAQ

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.

Stats: spent vs saved

/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.

Limits

  • Cannot clean old context (hooks cannot edit history).
  • Chunk-level scoring can be wrong; mitigations above.
  • Tool output leaves your machine. Check your data policy.
  • Decisions is in public beta and the API may change; auth is an API key.
  • Only the tools in the hook matcher are pruned; Read is excluded on purpose (pruning can break edits).

Contributing

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.

License

MIT. See LICENSE. If you use this in research or writing, cite via CITATION.cff.

Source 1 files
hooks/stats-ui.tsx 144 lines
1// 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