The agent compacts its own context between tasks, with a focus it writes for the compactor, and carries on by itself

TL;DR: the agent compacts itself gracefully: at a moment of its own choosing, between tasks, with the one it is on finished. Settings: ask the agent, "what are the smart-compact settings?"
claude plugin marketplace add zienag/alfreds-plugins
claude plugin install smart-compact@alfreds-plugins
Auto-compact fires at a fixed size, high up in a 1M window, at a random point of the task. The model does not see it coming and finishes nothing before it, so the work can come out broken. A lower limit does not help: for some tasks the right call is to go on a little and finish, then compact. A hard line is the wrong tool.
This mod asks instead. Past a certain size, tool results carry a nudge to compact between tasks, more insistent as the context grows, each step said once. The agent calls compact_me with a tweet-sized focus for the compactor (the task it continues with, the ones that are done) and ends the turn. The mod runs /compact with that focus and sends "Context compacted. Continue the task." Nobody has to be at the keyboard.
A subagent has no auto-compact at all, and the main agent can hand the same subagent task after task while its context grows unwatched. So on the same steps of its own context a subagent is asked to finish at a good point and return to its parent either the finished work or a hand-over for a fresh agent.
Nudges start at 250k, 300k and 400k tokens in a 1M window, at 60%, 70% and 80% of a smaller one.
startAt: first nudge, in thousands of tokens; the later steps follow in proportion (150 gives 150k, 180k, 240k). 0 is the ladder above.beforeCompact: a step the agent takes first, e.g. run the debrief skill.summaryNote: an instruction the compactor gets every time, e.g. the language of the summary.Ask the agent, or run claude plugin configure smart-compact@alfreds-plugins.
claude --plugin-dir plugins/smart-compact loads the folder, claude plugin test plugins/smart-compact runs the tests, and docs/smart-compact.md holds the engine facts the design rests on.
hooks/register.ts 121 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 FOCUS_LIMIT, compactInstructions, contextTokens, decide, handoverText, levelsFor, nudgeText, resumeText, squash,
5} from './ladder'
6
7const NAME = 'compact_me'
8const TOOL = 'mcp__smart-compact__compact_me'
9
10const focus = { plugin: 'smart-compact', key: 'focus' } as const
11const resuming = { plugin: 'smart-compact', key: 'resuming' } as const
12
13export const register: Register = (on, options) => {
14 const before = String(options.beforeCompact ?? '').trim()
15 const note = String(options.summaryNote ?? '').trim()
16 const startAt = Math.max(0, Number(options.startAt ?? 0) || 0) * 1000
17 let nudged = 0
18 let queued = false
19 const agents = new Map<string, { tokens: number; nudged: number }>()
20
21 on('session.start', async ($, e, next) => {
22 await $.tool.register({
23 name: NAME,
24 description:
25 'Compact this conversation once the current turn ends, then carry on with the task. ' +
26 'Call it as the last action of a turn, then end the turn. ' +
27 `focus: a tweet-size instruction (up to ${FOCUS_LIMIT} characters) for the compactor ` +
28 'naming the task the work continues with and the finished ones; never a summary.',
29 inputSchema: {
30 type: 'object',
31 properties: { focus: { type: 'string', description: 'What the summary should keep in detail' } },
32 required: ['focus'],
33 },
34 isDeferred: false,
35 })
36 const started = await next(e)
37 await resume($)
38 return started
39 })
40
41 on('tool.call', { tool: TOOL }, async ($, e) => {
42 if (e.agentId !== undefined) return { deny: `${NAME}: only the main session compacts.` }
43 const text = squash(typeof e.focus === 'string' ? e.focus : '')
44 if (text === '') return { deny: `${NAME}: focus is empty. Name the task the work continues with. Nothing queued.` }
45 if (text.length > FOCUS_LIMIT) {
46 return { deny: `${NAME}: focus is ${text.length} characters, limit ${FOCUS_LIMIT}. Shorten it. Nothing queued.` }
47 }
48 await $.state.set(focus, text)
49 queued = true
50 return { result: 'Queued: the conversation is compacted once this turn ends, and the task continues after it. End the turn now.' }
51 }).catch(($, e, next) => (next.called ? next(e) : { deny: `${NAME}: failed, nothing queued.` }))
52
53 on('turn.step', async function* ($, e, next) {
54 const step = yield* next(e)
55 if (e.agentId !== undefined && step.usage !== null) {
56 agents.set(e.agentId, { tokens: contextTokens(step.usage), nudged: agents.get(e.agentId)?.nudged ?? 0 })
57 }
58 return step
59 })
60
61 on('tool.call', async ($, e, next) => {
62 const ran = await next(e)
63 if (e.tool === TOOL || ran.deny !== undefined) return ran
64 const agent = e.agentId
65 if (agent === undefined && queued) return ran
66 const { tokens: mainTokens = 0, window } = (await $.session.usage()).context
67 const levels = levelsFor(window, startAt)
68 if (agent === undefined) {
69 const [due, remember] = decide(mainTokens, nudged, levels)
70 const first = nudged === 0
71 nudged = remember
72 if (due === null) return ran
73 return { ...ran, context: [...(ran.context ?? []), nudgeText(mainTokens, first, levels, before, TOOL)] }
74 }
75 const who = agents.get(agent)
76 if (who === undefined) return ran
77 const [due, remember] = decide(who.tokens, who.nudged, levels)
78 who.nudged = remember
79 if (due === null) return ran
80 return { ...ran, context: [...(ran.context ?? []), handoverText(who.tokens, levels)] }
81 }).catch(($, e, next) => next(e))
82
83 on('turn.complete', async ($, e, next) => {
84 const done = await next(e)
85 if (e.agentId !== undefined) {
86 agents.delete(e.agentId)
87 return done
88 }
89 queued = false
90 const { value: planned = null } = await $.state.get(focus)
91 if (planned === null) return done
92 await $.state.set(focus, null)
93 if (e.reason !== 'answer') {
94 $.ui.toast('smart-compact: the turn was interrupted, the queued compaction dropped')
95 return done
96 }
97 await $.state.set(resuming, resumeText())
98 $.clock.after(0, () => void compact($, compactInstructions(planned, before, note)))
99 return done
100 })
101}
102
103async function compact($: EngineInterface, instructions: string): Promise<void> {
104 try {
105 await $.command.run({ command: 'compact', args: instructions })
106 } catch (error) {
107 await $.state.set(resuming, null)
108 $.ui.toast(`smart-compact: /compact failed: ${String(error)}`)
109 return
110 }
111 await resume($)
112}
113
114/** Submits the planned continue prompt once; a reload of the mod during /compact submits it from session.start. */
115async function resume($: EngineInterface): Promise<void> {
116 const { value: planned = null } = await $.state.get(resuming)
117 if (planned === null) return
118 await $.state.set(resuming, null)
119 await $.prompt.submit({ text: planned })
120}
121hooks/ladder.ts 83 lines1export type Levels = readonly (readonly [start: number, step: number])[]
2
3/** [start, step] for a 1M window, and the share of a smaller window a start is capped at. */
4const LADDER = [
5 [250_000, 25_000, 0.6],
6 [300_000, 20_000, 0.7],
7 [400_000, 10_000, 0.8],
8] as const
9export const FOCUS_LIMIT = 280
10
11const FOCUS_DOC =
12 'The focus is a tweet-size extra instruction for the compactor, so it understands your intent: ' +
13 'which task you will continue. A summary or a retelling of key numbers and facts is FORBIDDEN here. ' +
14 'For example: focus "focus on the auth bug fix; the deploy is finished".'
15
16/** startAt (tokens) moves the whole ladder in proportion to its first start, whatever the window; 0 is automatic. */
17export function levelsFor(window: number, startAt = 0): Levels {
18 return LADDER.map(([start, step, share]) => {
19 const scale = startAt > 0 ? startAt / LADDER[0][0] : Math.min(1, (share * window) / start)
20 return [Math.round(start * scale), Math.round(step * scale)] as const
21 })
22}
23
24export function levelFor(tokens: number, levels: Levels): number {
25 let level = 0
26 for (const [start, step] of levels) {
27 if (tokens >= start) level = start + Math.floor((tokens - start) / step) * step
28 }
29 return level
30}
31
32/** [level to nudge about or null, level to remember]; a size that fell re-arms the ladder. */
33export function decide(tokens: number, nudged: number, levels: Levels): [number | null, number] {
34 const level = levelFor(tokens, levels)
35 return [level > nudged ? level : null, level]
36}
37
38export function nudgeText(tokens: number, first: boolean, levels: Levels, before: string, tool: string): string {
39 const [[calm = 0] = [], [pressing = 0] = [], [urgent = 0] = []] = levels
40 const how = `${before ? `${before}, then ` : ''}call the tool \`${tool}\` with a focus and end the turn.`
41 const k = (n: number) => Math.floor(n / 1000)
42 let text
43 if (tokens >= urgent) text = `Context ${k(tokens)}k > ${k(urgent)}k. Compact immediately: ${how}`
44 else if (tokens >= pressing) {
45 text = `Context ${k(tokens)}k > ${k(pressing)}k. Strongly advised to compact already: at the next gap between subtasks, ${how}`
46 } else text = `Context ${k(tokens)}k > ${k(calm)}k. Between tasks or subtasks, ${how}`
47 return first ? `${text} ${FOCUS_DOC}` : text
48}
49
50const HANDOVER =
51 'write a hand-over for a fresh agent (done, left, next step, dead ends, touched files) to a new file ' +
52 'in the session scratchpad or a temporary directory and end. Open your reply with: the hand-over in <path> ' +
53 'is for the next agent, no need to read it, just give that agent the path instead of resuming me.'
54
55/** The subagent's ladder: nothing compacts a subagent, so it hands its work over instead. */
56export function handoverText(tokens: number, levels: Levels): string {
57 const [, [pressing = 0] = [], [urgent = 0] = []] = levels
58 const size = `Context ${Math.floor(tokens / 1000)}k`
59 if (tokens >= urgent) return `${size}. Stop now: ${HANDOVER}`
60 if (tokens >= pressing) {
61 return `${size}. Three quarters of your task done? Finish it. Less? At the next stopping point ${HANDOVER}`
62 }
63 return `${size}: a lot, not a limit. Two thirds of your task done? Carry on. Less? Finish the piece you are on, then ${HANDOVER}`
64}
65
66/** A request's whole context: what it read, cached or not, and what it wrote. */
67export function contextTokens(usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number {
68 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
69}
70
71export function squash(text: string): string {
72 return text.split(/\s+/).filter(Boolean).join(' ')
73}
74
75export function compactInstructions(focus: string, before: string, note: string): string {
76 const routine = `Omit the compaction routine itself: context nudges, ${before ? 'the step before compacting, ' : ''}compact_me.`
77 return [focus, routine, note].filter(Boolean).join(' ')
78}
79
80export function resumeText(): string {
81 return 'Context compacted. Continue the task. If the turn before ended with a question to the user, wait for the answer instead.'
82}
83types/index.d.ts 15 lines1export type Focus = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'smart-compact': {
6 focus: Focus
7 resuming: string | null
8 }
9 }
10
11 interface McpToolInputs {
12 'mcp__smart-compact__compact_me': { focus: string }
13 }
14}
15