SLOPSHOPPER

smart-compact

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

newguardtoasttooltimer
★ 1v0.2.5no licenseupdated 2026-10-09zienag/alfreds-plugins/plugins/smart-compact
A shopper browsing a rack in a slop shop
README

smart-compact

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

Install

claude plugin marketplace add zienag/alfreds-plugins
claude plugin install smart-compact@alfreds-plugins

Why

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.

Options

  • 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.

Working on it

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.

Source 3 files
hooks/register.ts 121 lines
1import 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}
121
hooks/ladder.ts 83 lines
1export 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}
83
types/index.d.ts 15 lines
1export 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