SLOPSHOPPER

smart-compact

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…

newpanebandguardtoastprompt
★ 2v0.2.0MITupdated 2026-10-13hellosverre/smart-compact
A shopper browsing a rack in a slop shop
README

<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">

Install

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.

Update

claude plugin marketplace update smart-compact
claude plugin update smart-compact@smart-compact

Uninstall

claude plugin uninstall smart-compact@smart-compact

When it speaks up

MomentThe offer readsWhy then
Claude calls request_compactClaude wants to compact · its reasonClaude knows when a task is finished better than a token counter does
A git commit landsGood moment to compact · You just committedThe work is saved; the turns that led to it are rarely needed verbatim
Tests go greenGood moment to compact · Tests just went greenSame, for vitest, jest, pytest, mocha, playwright, cargo, go, dotnet and npm/pnpm/yarn/bun test
You have been idle almost 5 minutesGood moment to compact · Prompt cache expires in ~1 minSee 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">

Claude can ask for it

smart-compact gives Claude a tool, request_compact, with two fields:

Field
reasonOne line: why now is a good moment. Shown in the offer.
instructionsWhat 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.

Why the cache matters

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.

Nothing is lost

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 &lt;time&gt; in /resume to go back to it as it was. Claude Code clears old sessions after cleanupPeriodDays (30 by default), backups included.

Settings

In the plugin's options under /plugin:

SettingDefault
onRequestaskask: offer it when Claude requests a compact. auto: compact as soon as the turn ends.
minPercent40Context fill below which nothing is suggested and Claude's requests are turned down
cacheTtlMinutes5Set 60 if your plan uses the 1-hour cache
idleCompactfalseCompact on its own a minute before the cache expires, instead of only suggesting it
backuptrueBefore every compact, copy the full conversation to its own session

How it works

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 run
  • turn.complete decides whether to suggest, compacts on an auto request, and starts the cache timer
  • prompt.submit cancels the timer and clears the offer
  • classic.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 fits

Compaction always waits for the turn to end; smart-compact never interrupts Claude mid-answer.

Develop

claude plugin test .
claude plugin validate .

python scripts/demo.py redraws the GIF and stills (Pillow and ffmpeg).

More mods

  • skins: tool rows with icons, diff and chart cards, fifteen themes
  • redgreen: test results in a pane beside the chat
  • mod-store: browse, search and install 2,700 mods from a pane

MIT licensed.

Source 4 files
hooks/register.tsx 259 lines
1import { 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}
259
hooks/backup.ts 44 lines
1// 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}
44
hooks/signals.ts 28 lines
1// 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}
28
types/index.d.ts 15 lines
1export 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