Compacts at the right moment: after a commit or green tests, a minute before the prompt cache expires, or when Claude asks for it with its own request_compact…

<h1 align="center"> <img alt="smart-compact" src="docs/brand/banner-dark.svg" width="640"> </h1>
<a href="https://github.com/hellosverre/smart-compact/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/hellosverre/smart-compact?style=flat-square&color=555"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-555?style=flat-square"></a> <img alt="Claude Code 2.1.295+" src="https://img.shields.io/badge/Claude%20Code-2.1.295%2B-555?style=flat-square">
<img alt="Claude runs the tests, commits, then calls request_compact with what the summary must keep; when the turn ends a dialog above the prompt reads 'Claude wants to compact · token refactor shipped · context 74%' with Compact now focused and Not now; Enter and the conversation is compacted, context back to 9%" src="docs/demo.gif">
Needs Claude Code 2.1.295 or later, in a terminal or the desktop app's Code tab. Run both inside Claude Code:
/plugin marketplace add hellosverre/smart-compact
/plugin install smart-compact@smart-compact
Restart Claude Code. Nothing to set up: smart-compact stays out of the way until the context is 40% full.
claude plugin marketplace update smart-compact
claude plugin update smart-compact@smart-compact
claude plugin uninstall smart-compact@smart-compact
| Moment | The offer reads | Why then |
|---|---|---|
Claude calls request_compact | Claude wants to compact · its reason | Claude knows when a task is finished better than a token counter does |
A git commit lands | Good moment to compact · You just committed | The work is saved; the turns that led to it are rarely needed verbatim |
| Tests go green | Good moment to compact · Tests just went green | Same, for vitest, jest, pytest, mocha, playwright, cargo, go, dotnet and npm/pnpm/yarn/bun test |
| You have been idle almost 5 minutes | Good moment to compact · Prompt cache expires in ~1 min | See below |
The offer opens as a small dialog above the prompt that already has the keyboard: ←/→ pick, Enter confirms (or c / n), Esc hands the keys back. Where the terminal is too narrow for it, the band carries the same buttons (click it or ctrl+x tab first). Sending a new prompt clears either. Nothing shows while the context is under the threshold, and nothing fires inside subagents.
<img alt="The dialog after a few idle minutes: 'Good moment to compact · Prompt cache expires in ~1 min, then 144k tokens re-send at full price · context 72%'" src="docs/cache.png">
smart-compact gives Claude a tool, request_compact, with two fields:
| Field | |
|---|---|
reason | One line: why now is a good moment. Shown in the offer. |
instructions | What the summary must keep: open TODOs, file paths, decisions. Passed to the compaction. |
The tool's description tells Claude to call it at a natural boundary (a task finished and committed, a plan written down, a switch to an unrelated topic) and never mid-task. A request while the context is under the threshold is turned down with a note telling Claude to keep working, so it cannot compact a short session.
By default a request only offers the compact. Set onRequest to auto and it compacts as soon as the turn ends.
Claude Code caches the conversation for 5 minutes after each turn. Compacting reads the whole context, so compacting while the cache is warm reads it at cache price. Wait past expiry and the next message, compact or not, re-sends every token at full price. At 150k tokens that is the difference between a tenth of the cost and all of it.
So a minute before the cache expires, smart-compact offers the compact. Turn on idleCompact and it does it for you while you are away.
Before every compact (yours, auto-compact's or smart-compact's), the whole conversation is copied to a session of its own. Pick Before compact <time> in /resume to go back to it as it was. Claude Code clears old sessions after cleanupPeriodDays (30 by default), backups included.
In the plugin's options under /plugin:
| Setting | Default | |
|---|---|---|
onRequest | ask | ask: offer it when Claude requests a compact. auto: compact as soon as the turn ends. |
minPercent | 40 | Context fill below which nothing is suggested and Claude's requests are turned down |
cacheTtlMinutes | 5 | Set 60 if your plan uses the 1-hour cache |
idleCompact | false | Compact on its own a minute before the cache expires, instead of only suggesting it |
backup | true | Before every compact, copy the full conversation to its own session |
A Claude Code mod in three files (hooks/register.tsx, hooks/signals.ts, hooks/backup.ts):
tool.call on Bash and PowerShell watches for a commit or a green test runturn.complete decides whether to suggest, compacts on an auto request, and starts the cache timerprompt.submit cancels the timer and clears the offerclassic.PreCompact copies the transcript to a new session first (hooks/backup.ts), with sed or PowerShell through $.process.run, since transcripts often pass the 4 MiB $.fs takes$.tool.register adds request_compact, and $.session.compact runs the compaction with Claude's instructions; where that is not available (the desktop app's Code tab, the SDK) it queues your own /compact <instructions>$.ui.open places the dialog, drawn by a ui.render hook on its Pane; a hook on AbovePrompt draws the band where no dialog fitsCompaction always waits for the turn to end; smart-compact never interrupts Claude mid-answer.
claude plugin test .
claude plugin validate .
python scripts/demo.py redraws the GIF and stills (Pillow and ffmpeg).
MIT licensed.
hooks/register.tsx 259 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, ResolveInput, Timer } from 'claude-code'
3
4import type { Suggestion } from '../types'
5import { backupPlan } from './backup'
6import { boundaryOf, cacheWarningDelayMs, formatTokens } from './signals'
7import type { Boundary } from './signals'
8
9const TOOL = 'request_compact'
10const TOOL_ID = 'mcp__smart-compact__request_compact'
11const DIALOG = 'smart-compact'
12
13const suggestion = atom({ plugin: 'smart-compact', key: 'suggestion' } as const, null)
14
15const DESCRIPTION = [
16 'Ask to compact the conversation once this turn ends.',
17 'Call it at a natural boundary: a task is finished and committed, a plan is',
18 'written down, or the work is about to switch to an unrelated topic, and the',
19 'details so far will not be needed verbatim. Do not call it mid-task.',
20 'Put what the summary must keep (open TODOs, file paths, decisions) in',
21 '`instructions`. The person may approve it with one click or it may run on its own.',
22].join(' ')
23
24// $-taking helpers live at the top level: the validator follows $ only into
25// functions declared here.
26async function percentNow($: Engine) {
27 const { context } = await $.session.usage()
28 return { percent: context.percent ?? 0, tokens: context.tokens }
29}
30
31// Offers it in a small dialog that takes the keyboard (arrows pick, Enter
32// confirms, Esc hands the keys back). Where the surface will not place one, the
33// band above the prompt carries the same buttons.
34async function suggest($: Engine, next: Suggestion) {
35 await update($, suggestion, () => next)
36 const opened = await $.ui.open({ id: DIALOG, title: 'smart-compact', focus: true, closeOnEscape: true, rows: 1 })
37 if (opened.isPlaced) await update($, suggestion, s => (s === null ? s : { ...s, inDialog: true }))
38}
39
40async function dismiss($: Engine) {
41 await update($, suggestion, () => null)
42 // A dialog that will not close must not stand between the person and the compact.
43 await $.ui.close({ id: DIALOG }).catch((error: unknown) => $.ui.toast(`smart-compact: ${String(error)}`))
44}
45
46async function compact($: Engine, instructions?: string) {
47 await dismiss($)
48 try {
49 const done = await $.session.compact(instructions ? { instructions } : undefined)
50 if (done.skip !== undefined) $.ui.toast(`smart-compact: skipped (${done.skip})`)
51 } catch {
52 await compactAsCommand($, instructions)
53 }
54}
55
56// A headless session (the desktop app's Code tab, the SDK) compacts only inside
57// a turn, so it gets the person's own /compact, queued until the session is idle.
58async function compactAsCommand($: Engine, instructions?: string) {
59 try {
60 await $.command.run({ command: 'compact', args: instructions ?? '' })
61 } catch (error) {
62 $.ui.toast(`smart-compact: could not compact, type /compact (${String(error)})`)
63 }
64}
65
66function offer($: Engine, e: ResolveInput, current: Suggestion) {
67 const { Box, Button, Text } = $.ui.resolve(e)
68 const lead = current.source === 'claude' ? 'Claude wants to compact' : 'Good moment to compact'
69
70 return (
71 <Box key="smart-compact" flexDirection="row" gap={1}>
72 <Text>
73 <Text bold>{lead}</Text>
74 <Text dimColor>
75 {' '}
76 � {current.reason} � context {current.percent}%
77 </Text>
78 </Text>
79 <Button
80 key="compact"
81 label="Compact now"
82 hotkey="c"
83 variant="primary"
84 autoFocus
85 onPress={() => compact($, current.instructions)}
86 />
87 <Button key="dismiss" label="Not now" hotkey="n" onPress={() => dismiss($)} />
88 </Box>
89 )
90}
91
92// A copy of the transcript as its own session before the compact replaces it.
93// A failed copy is said out loud and the compact goes ahead: the old lines stay on disk.
94async function backUp($: Engine, transcript: string, sessionId: string) {
95 const plan = backupPlan(transcript, sessionId, crypto.randomUUID(), new Date(await $.clock.now()))
96 const ran = await $.process.run(plan.argv, { env: plan.env, timeoutMs: 60_000 })
97 if (ran.exitCode !== 0) {
98 throw new Error(`exit ${ran.exitCode}: ${ran.stderr.trim().slice(0, 200)}`)
99 }
100}
101
102export const register: Register = (on, options) => {
103 const onRequest = options.onRequest === 'auto' ? 'auto' : 'ask'
104 const minPercent = Number(options.minPercent ?? 40)
105 const ttlMinutes = Number(options.cacheTtlMinutes ?? 5)
106 const idleCompact = options.idleCompact === true
107 const backup = options.backup !== false
108
109 // Per turn: what landed during it, and what Claude asked for.
110 let boundary: Boundary | null = null
111 let requested: { reason: string; instructions?: string } | null = null
112 let cacheTimer: Timer | null = null
113
114 on('session.start', async ($, e, next) => {
115 await $.tool.register({
116 name: TOOL,
117 description: DESCRIPTION,
118 isDeferred: false,
119 inputSchema: {
120 type: 'object',
121 properties: {
122 reason: { type: 'string', description: 'One line: why now is a good moment.' },
123 instructions: { type: 'string', description: 'What the summary must keep.' },
124 },
125 required: ['reason'],
126 },
127 })
128 return next(e)
129 })
130
131 on('tool.call', { tool: TOOL_ID }, async ($, e) => {
132 const input = e as unknown as { reason?: unknown; instructions?: unknown }
133 const reason = typeof input.reason === 'string' ? input.reason.trim() : ''
134 const instructions =
135 typeof input.instructions === 'string' && input.instructions.trim() !== ''
136 ? input.instructions.trim()
137 : undefined
138
139 if (reason === '') {
140 return { deny: 'request_compact needs a `reason`.' }
141 }
142
143 const { percent } = await percentNow($)
144 if (percent < minPercent) {
145 return {
146 result: `Not queued: context is only ${percent}% full (threshold ${minPercent}%). Keep working.`,
147 }
148 }
149
150 requested = { reason, instructions }
151 const what =
152 onRequest === 'auto'
153 ? 'The conversation will compact as soon as this turn ends.'
154 : 'The person will be offered a one-click compact when this turn ends.'
155 return {
156 result: `Queued at ${percent}% context. ${what} Finish the turn with a short status line.`,
157 }
158 }).catch(($, e, next) =>
159 next.called ? next(e) : { deny: 'smart-compact: request_compact failed; carry on.' },
160 )
161
162 // Both shells land work the same way; a subagent's commits are its own business.
163 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
164 const ran = await next(e)
165 if (ran.deny === undefined && !e.agentId) {
166 boundary = boundaryOf(e.command, ran.isError === true) ?? boundary
167 }
168 return ran
169 }).catch(($, e, next) => next(e))
170
171 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
172 const ran = await next(e)
173 if (ran.deny === undefined && !e.agentId) {
174 boundary = boundaryOf(e.command, ran.isError === true) ?? boundary
175 }
176 return ran
177 }).catch(($, e, next) => next(e))
178
179 on('prompt.submit', async ($, e, next) => {
180 cacheTimer?.cancel()
181 cacheTimer = null
182 boundary = null
183 requested = null
184 await dismiss($)
185 return next(e)
186 }).catch(($, e, next) => next(e))
187
188 on('turn.complete', async ($, e, next) => {
189 const done = await next(e)
190 if (e.agentId || e.isAborted) return done
191
192 const { percent, tokens } = await percentNow($)
193 const asked = requested
194 const landed = boundary
195 requested = null
196 boundary = null
197
198 if (asked) {
199 if (onRequest === 'auto') {
200 // session.compact rejects while a turn runs; this fires once it has ended.
201 $.clock.after(300, () => void compact($, asked.instructions))
202 } else {
203 await suggest($, { source: 'claude', percent, ...asked })
204 }
205 return done
206 }
207
208 if (percent < minPercent) return done
209
210 if (landed) {
211 await suggest($, { source: 'boundary', percent, reason: landed === 'committed' ? 'You just committed' : 'Tests just went green' })
212 }
213
214 cacheTimer?.cancel()
215 cacheTimer = $.clock.after(cacheWarningDelayMs(ttlMinutes), () => {
216 cacheTimer = null
217 if (idleCompact) {
218 void compact($)
219 return
220 }
221 void suggest($, {
222 source: 'cache',
223 percent,
224 reason: `Prompt cache expires in ~1 min, then ${formatTokens(tokens)} re-send at full price`,
225 })
226 })
227
228 return done
229 })
230
231 // Every compact, the person's /compact and auto-compact included, not only ours.
232 on('classic.PreCompact', async ($, e, next) => {
233 if (backup) {
234 await backUp($, e.transcript_path, e.session_id).catch((error: unknown) =>
235 $.ui.toast(`smart-compact: no backup this time (${error instanceof Error ? error.message : String(error)})`),
236 )
237 }
238 return next(e)
239 }).catch(($, e, next) => next(e))
240
241 // Esc on the dialog means not now.
242 on('ui.close', { id: DIALOG }, async ($, e, next) => {
243 await update($, suggestion, () => null)
244 return next(e)
245 }).catch(($, e, next) => next(e))
246
247 on('ui.render', { component: 'Pane', requestId: DIALOG }, async ($, e, next) => {
248 const current = await read($, suggestion)
249 if (current === null) return next(e)
250 return offer($, e, current)
251 })
252
253 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
254 const current = await read($, suggestion)
255 if (current === null || current.inDialog || e.props.hasSurvey) return next(e)
256 return offer($, e, current)
257 })
258}
259hooks/backup.ts 44 lines1// Before a compact, a copy of the whole transcript as a session of its own, so
2// /resume can go back to the conversation as it was. Compaction keeps the old
3// lines on disk but resumes from its summary; the copy resumes from the full text.
4//
5// The copy runs as a host command: transcripts that need compacting are often
6// past the 4 MiB a plugin's $.fs read or write takes.
7
8const POSIX = 'sed "s/$SC_OLD/$SC_NEW/g" "$SC_IN" > "$SC_OUT" && printf "%s" "$SC_TAIL" >> "$SC_OUT"'
9const WINDOWS =
10 "$ErrorActionPreference = 'Stop'; " +
11 '$text = [IO.File]::ReadAllText($env:SC_IN).Replace($env:SC_OLD, $env:SC_NEW); ' +
12 '[IO.File]::WriteAllText($env:SC_OUT, $text + $env:SC_TAIL)'
13
14export type BackupPlan = { argv: string[]; env: Record<string, string>; out: string }
15
16const isWindowsPath = (path: string) => /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
17
18// The /resume list shows a custom title, so the copy says what it is and when.
19export function backupTitle(at: Date): string {
20 const pad = (n: number) => String(n).padStart(2, '0')
21 return `Before compact ${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
22}
23
24export function backupPlan(transcript: string, sessionId: string, newId: string, at: Date): BackupPlan {
25 const ids = /^[0-9a-f-]{36}$/i
26 if (!ids.test(sessionId) || !ids.test(newId)) {
27 throw new Error(`smart-compact backup: unexpected session id ${sessionId}`)
28 }
29
30 const slash = Math.max(transcript.lastIndexOf('/'), transcript.lastIndexOf('\\'))
31 if (slash === -1 || !transcript.endsWith('.jsonl')) {
32 throw new Error(`smart-compact backup: no transcript file at ${transcript}`)
33 }
34
35 const out = `${transcript.slice(0, slash + 1)}${newId}.jsonl`
36 const tail = `${JSON.stringify({ type: 'custom-title', customTitle: backupTitle(at), sessionId: newId })}\n`
37 const env = { SC_IN: transcript, SC_OUT: out, SC_OLD: sessionId, SC_NEW: newId, SC_TAIL: tail }
38 const argv = isWindowsPath(transcript)
39 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', WINDOWS]
40 : ['sh', '-c', POSIX]
41
42 return { argv, env, out }
43}
44hooks/signals.ts 28 lines1// Reads a finished Bash call for a "work just landed" moment: the points where
2// the conversation behind it is least likely to be needed verbatim again.
3
4const COMMIT = /\bgit\s+(-C\s+\S+\s+)?commit\b/
5const TESTS =
6 /\b(vitest|jest|pytest|mocha|playwright\s+test|cargo\s+(test|nextest)|go\s+test|dotnet\s+test|(npm|pnpm|yarn|bun)\s+(run\s+)?test)\b/
7
8export type Boundary = 'committed' | 'tests passed'
9
10export function boundaryOf(command: string, isError: boolean): Boundary | null {
11 if (isError) return null
12 if (COMMIT.test(command)) return 'committed'
13 if (TESTS.test(command)) return 'tests passed'
14 return null
15}
16
17// One minute before expiry: late enough that the person has likely moved on,
18// early enough that the summarizer still reads the context from cache.
19export function cacheWarningDelayMs(ttlMinutes: number): number {
20 const ttlMs = Math.max(1, ttlMinutes) * 60_000
21 return Math.max(30_000, ttlMs - 60_000)
22}
23
24export function formatTokens(tokens: number | undefined): string {
25 if (tokens === undefined) return 'the whole context'
26 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k tokens` : `${tokens} tokens`
27}
28types/index.d.ts 15 lines1export type Suggestion = {
2 source: 'claude' | 'boundary' | 'cache'
3 reason: string
4 instructions?: string
5 percent: number
6 // True once the dialog above the prompt holds it; the band stands in otherwise.
7 inDialog?: boolean
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'smart-compact': { suggestion: Suggestion | null }
13 }
14}
15