SLOPSHOPPER

compact-meter

Measure only: log what a /compact keeps and what Claude Code re-attaches after it (restored Reads, invoked skills, instructions, listings), by size, to…

newprompt
v0.1.0MITupdated 2026-10-24je2020je/compact-meter
A shopper browsing a rack in a slop shop
README

compact-meter

A Claude Code mod that measures what a /compact keeps and what the engine re-attaches after it. It changes nothing the model reads: every hook returns the engine's own result unchanged.

Use it before you cut waves, STATE files or skills to "save context after a compact". Measure first.

Install

At the prompt of a Claude Code terminal session:

/plugin install compact-meter --marketplace je2020je/compact-meter

Answer y to add the marketplace, then pick a scope. From a shell:

claude plugin install compact-meter --marketplace je2020je/compact-meter

Read it

After a compact:

tail -n 60 ~/.claude/compact-meter.log

The log is $COMPACT_METER_LOG when that variable is set, else $HOME/.claude/compact-meter.log.

rowwhat it is
== … compact trigger= before= after= kept=tokens before and after the compact, and the kept message count
kept[i] role chars=each kept message (the summary, the last reply)
row door=attachment name=…rows written to the transcript after the compact
attach type=… chars=…blocks injected per request after the compact: file (a restored Read), compact_file_reference, invoked_skills, skill_listing, instructions, …

Sizes are characters; divide by about 3.5 for English tokens.

What we found with it

In a long multi-wave session (122k tokens before the compact), the restored file Reads were about 1k tokens and the restored skills about 9k. The rest of what a compact re-attaches (skill list, CLAUDE.md, memory, environment) is what a brand-new session loads anyway. A compact cost about the same as a fresh start. The large context was the session's own work, not the restoration.

How it works

Three hooks: session.compact (kept messages), session.append (rows after the compact) and prompt.attachment (per-request blocks, where the restored Reads and skills live). A prompt.attachment hook that returned { text: null } would drop a block; this mod never does.

License

MIT

Source 1 files
hooks/register.ts 53 lines
1import type { Register } from 'claude-code'
2
3// Measure only. It changes nothing the model reads: every hook returns next(e) as resolved.
4// Sizes are characters (~3.5 chars per token for English, fewer for Persian).
5// The log is $COMPACT_METER_LOG when set, else $HOME/.claude/compact-meter.log: no machine path is written here.
6type Io = { env: { get: (n: string) => Promise<string | undefined> }; fs: { write: (p: string, s: string) => Promise<unknown> } }
7let LOG = ''
8const lines: string[] = []
9let afterCompact = 0 // rows still to log after a compaction
10let afterAttach = 0 // per-request attachments still to log after a compaction
11
12const size = (c: unknown) => JSON.stringify(c ?? '').length
13const head = (s: string) => s.replace(/\s+/g, ' ').slice(0, 50)
14const flush = async ($: Io) => {
15  LOG ||= (await $.env.get('COMPACT_METER_LOG')) || `${(await $.env.get('HOME')) ?? '.'}/.claude/compact-meter.log`
16  await $.fs.write(LOG, lines.join('\n') + '\n')
17}
18
19export const register: Register = on => {
20  on('session.compact', async ($, e, next) => {
21    const r = await next(e)
22    if (r.skip === undefined && !e.agentId) {
23      lines.push(`== ${new Date().toISOString()} compact trigger=${e.trigger} before=${r.tokensBefore ?? '?'} after=${r.tokensAfter ?? '?'} kept=${r.messages.length}`)
24      r.messages.forEach((m, i) => lines.push(`  kept[${i}] ${m.role} chars=${m.text.length} tools=${m.toolUses.length} "${head(m.text)}"`))
25      afterCompact = 60
26      afterAttach = 40
27      await flush($)
28    }
29    return r
30  })
31
32  // The restored Reads and skills do not pass session.append: they are rendered per request, here.
33  on('prompt.attachment', async ($, e, next) => {
34    const r = await next(e)
35    if (!e.agentId && afterAttach > 0) {
36      afterAttach -= 1
37      lines.push(`  attach type=${e.type} chars=${r.text?.length ?? 0} "${head(r.text ?? '')}"`)
38      await flush($)
39    }
40    return r
41  })
42
43  on('session.append', async ($, e, next) => {
44    const r = await next(e)
45    if (!e.agentId && (afterCompact > 0 || e.door === 'compaction')) {
46      afterCompact = Math.max(afterCompact - 1, 0)
47      lines.push(`  row door=${e.door} type=${e.message.type} name=${e.message.name ?? '-'} role=${e.message.role ?? '-'} chars=${size(e.message.content)}`)
48      await flush($)
49    }
50    return r
51  })
52}
53