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

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.
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
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.
| row | what 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.
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.
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.
MIT
hooks/register.ts 53 lines1import 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