SLOPSHOPPER

early-compact

Has Claude write a checkpoint once context passes a threshold, then compacts early with that checkpoint as the brief and resumes the work

newcommandtoaststatusprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · early-compact
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /early-compact ⎿ early-compact: Early compaction is on: checkpoint and compact at 60% of the window and 40,000+ tokens. ⎿ early-compact: Context now: 49% (97,400 tokens). Phase: watching. ⎿ early-compact: Last checkpoint: none yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 1 files
hooks/register.ts 306 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// watching: checks context fill after each main-loop turn
4// checkpointing: the checkpoint prompt is submitted or its turn is running
5// compacting: /compact is queued or running with the checkpoint as its brief
6type Phase = 'watching' | 'checkpointing' | 'compacting'
7
8const COMMAND = 'early-compact'
9const MAX_BRIEF_CHARS = 12_000
10const SNOOZE_POINTS = 10
11
12// Finds the checkpoint turn in turn.start whatever frame the prompt arrives in.
13const CHECKPOINT_MARK = 'write a checkpoint a fresh copy of you could resume from'
14
15export const CHECKPOINT_PROMPT = `Context is getting full, so this session will be compacted as soon as you reply. Before that, ${CHECKPOINT_MARK} with nothing else to go on.
16
17Do not call tools and do not continue the task: reply with the checkpoint alone, under these headings.
18
19- **Goal**: what the user asked for, in their terms, with every constraint and preference they stated.
20- **State**: what is done, what is in progress, and what is verified versus assumed.
21- **Key facts**: exact file paths, symbols, commands, branch names and SHAs, URLs, IDs and error messages.
22- **Decisions**: choices made and why, including approaches ruled out.
23- **Open questions**: anything waiting on the user or still unknown.
24- **Next steps**: the concrete next actions, in order.
25
26Facts over prose, at most about 800 words.`
27
28export function resumePrompt(path: string | undefined): string {
29  const source =
30    path === undefined
31      ? 'the checkpoint in the summary above'
32      : `the checkpoint saved at \`${path}\` (read it first: it is fuller than the summary)`
33
34  return `The conversation was just compacted early to free up context. Pick up where you left off, using ${source}.
35
36- If your last turn was partway through work the user asked for, or said it would carry on, continue it from Next steps.
37- If the next step needs the user (a question, an approval, a choice), say in a line or two what you are waiting for and stop.
38- Do not start work the user has not asked for.`
39}
40
41// The module's environment is fresh on every load, so this is per session.
42const session = {
43  phase: 'watching' as Phase,
44  isEnabled: true,
45  snoozeBelowPercent: 0,
46  checkpointTurnId: undefined as string | undefined,
47  // Counts the person's prompts, so a resume queued behind one is dropped.
48  userPrompts: 0,
49  resumeAfterUserPrompts: undefined as number | undefined,
50  checkpoints: 0,
51  lastCheckpointPath: undefined as string | undefined,
52}
53
54export function compactBrief(checkpoint: string, path: string | undefined): string {
55  const body =
56    checkpoint.length > MAX_BRIEF_CHARS
57      ? `${checkpoint.slice(0, MAX_BRIEF_CHARS)}\n[checkpoint cut at ${MAX_BRIEF_CHARS} characters]`
58      : checkpoint
59
60  return `This is an early compaction. The last assistant message is a checkpoint written for it: build the summary around that checkpoint, keep its Goal, Key facts, Decisions, Open questions and Next steps intact (exact strings stay exact), and add anything from earlier in the conversation it missed.${
61    path === undefined
62      ? ''
63      : ` The summary must state that the full checkpoint is saved at \`${path}\` and should be read before resuming.`
64  }
65
66<checkpoint>
67${body}
68</checkpoint>`
69}
70
71function reset($: EngineInterface) {
72  session.phase = 'watching'
73  session.snoozeBelowPercent = 0
74  session.checkpointTurnId = undefined
75  $.ui.status(undefined)
76}
77
78function snooze($: EngineInterface, percent: number, why: string) {
79  reset($)
80  session.snoozeBelowPercent = percent + SNOOZE_POINTS
81  $.ui.toast(`Early compaction ${why}; trying again at ${session.snoozeBelowPercent}%.`)
82}
83
84async function contextFill($: EngineInterface) {
85  const { context } = await $.session.usage()
86
87  return { percent: context.percent ?? 0, tokens: context.tokens ?? 0 }
88}
89
90// startCheckpoint and startCompact act from a timer: a $ call made inside a
91// hook the turn waits on is refused, and the prompt and the command each wait
92// for the session to go idle.
93function startCheckpoint($: EngineInterface) {
94  session.phase = 'checkpointing'
95  $.ui.status('early-compact: writing checkpoint')
96  $.clock.after(0, () => {
97    $.prompt
98      .submit({ text: CHECKPOINT_PROMPT })
99      .then(entered => {
100        if (entered.drop !== undefined) {
101          reset($)
102          $.ui.toast(`Early compaction stopped: ${entered.drop}`)
103        }
104      })
105      .catch(() => {
106        reset($)
107        $.ui.toast('Early compaction stopped: the checkpoint prompt was refused.')
108      })
109  })
110}
111
112// Saves the checkpoint where the next turn can Read it without a prompt: under
113// the project's .claude/, in a folder that git-ignores itself.
114async function saveCheckpoint($: EngineInterface, checkpoint: string, percent: number) {
115  const [root, sessionId, now] = await Promise.all([
116    $.session.root(),
117    $.session.id(),
118    $.clock.now(),
119  ])
120  const folder = `${root}/.claude/early-compact`
121  const stamp = new Date(now).toISOString()
122  session.checkpoints += 1
123  const path = `${folder}/${sessionId}/checkpoint-${session.checkpoints}.md`
124
125  await $.fs.write(`${folder}/.gitignore`, '*\n')
126  await $.fs.write(
127    path,
128    `# Checkpoint ${session.checkpoints}\n\nSession \`${sessionId}\`, written ${stamp} at ${percent}% context, just before an early compaction.\n\n${checkpoint.trim()}\n`,
129  )
130  session.lastCheckpointPath = path
131
132  return path
133}
134
135function startResume($: EngineInterface, path: string | undefined) {
136  const since = session.resumeAfterUserPrompts
137  session.resumeAfterUserPrompts = undefined
138  $.clock.after(0, () => {
139    // The person's own prompt, sent while this ran, carries on in its place.
140    if (since !== undefined && session.userPrompts > since) {
141      return
142    }
143    $.prompt.submit({ text: resumePrompt(path) }).catch(() => {
144      $.ui.toast('Compacted early, but could not resume: send a prompt to continue.')
145    })
146  })
147}
148
149function startCompact(
150  $: EngineInterface,
151  checkpoint: string,
152  path: string | undefined,
153  percent: number,
154) {
155  session.phase = 'compacting'
156  session.resumeAfterUserPrompts = session.userPrompts
157  $.ui.status('early-compact: compacting')
158  $.clock.after(0, () => {
159    $.command
160      .run({ command: 'compact', args: compactBrief(checkpoint, path) })
161      .then(() => {
162        // session.compact resets first when the compaction stands
163        if (session.phase === 'compacting') {
164          snooze($, percent, 'was skipped')
165        }
166      })
167      .catch(() => snooze($, percent, 'failed'))
168  })
169}
170
171export const register: Register = (on, options) => {
172  const thresholdPercent = Number(options.thresholdPercent ?? 60)
173  const minTokens = Number(options.minTokens ?? 40_000)
174
175  on('session.start', async ($, e, next) => {
176    await $.command.register({
177      name: COMMAND,
178      description: `Checkpoint and compact early (auto at ${thresholdPercent}%)`,
179      argumentHint: '[now|on|off|status]',
180    })
181
182    return next(e)
183  })
184
185  on('command.run', { command: COMMAND }, async ($, e) => {
186    const action = e.args.trim().toLowerCase() || 'status'
187
188    if (action === 'on' || action === 'off') {
189      session.isEnabled = action === 'on'
190      reset($)
191
192      return { text: `Early compaction ${action} for this session.` }
193    }
194
195    if (action === 'now') {
196      if (session.phase !== 'watching') {
197        return { text: `Early compaction is already ${session.phase}.` }
198      }
199      startCheckpoint($)
200
201      return { text: 'Writing a checkpoint, then compacting and resuming.' }
202    }
203
204    if (action !== 'status') {
205      return { text: `Unknown option "${action}". Use now, on, off or status.` }
206    }
207
208    const { percent, tokens } = await contextFill($)
209    const when = `at ${thresholdPercent}% of the window and ${minTokens.toLocaleString()}+ tokens`
210    const snoozed =
211      session.snoozeBelowPercent > thresholdPercent
212        ? ` (snoozed until ${session.snoozeBelowPercent}%)`
213        : ''
214
215    return {
216      text: [
217        `Early compaction is ${session.isEnabled ? 'on' : 'off'}: checkpoint and compact ${when}${snoozed}.`,
218        `Context now: ${percent}% (${tokens.toLocaleString()} tokens). Phase: ${session.phase}.`,
219        `Last checkpoint: ${session.lastCheckpointPath ?? 'none yet'}.`,
220      ].join('\n'),
221    }
222  })
223
224  on('prompt.submit', ($, e, next) => {
225    if (e.origin.kind !== 'plugin') {
226      session.userPrompts += 1
227    }
228
229    return next(e)
230  })
231
232  on('turn.start', ($, e, next) => {
233    if (session.phase === 'checkpointing' && e.text.includes(CHECKPOINT_MARK)) {
234      session.checkpointTurnId = e.turnId
235    }
236
237    return next(e)
238  })
239
240  on('turn.complete', async ($, e, next) => {
241    const result = await next(e)
242    if (e.agentId !== undefined) {
243      return result
244    }
245
246    if (e.turnId === session.checkpointTurnId) {
247      session.checkpointTurnId = undefined
248      const { percent } = await contextFill($)
249      if (e.reason === 'answer' && e.answer.trim() !== '') {
250        const path = await saveCheckpoint($, e.answer, percent).catch(() => {
251          session.lastCheckpointPath = undefined
252          $.ui.toast('Early compaction could not save the checkpoint file; it is in the summary.')
253
254          return undefined
255        })
256        startCompact($, e.answer, path, percent)
257      } else {
258        snooze($, percent, e.reason === 'aborted' ? 'was interrupted' : 'got no checkpoint')
259      }
260
261      return result
262    }
263
264    if (!session.isEnabled || session.phase !== 'watching' || e.reason !== 'answer') {
265      return result
266    }
267
268    const { percent, tokens } = await contextFill($)
269    const isDue =
270      percent >= thresholdPercent &&
271      percent >= session.snoozeBelowPercent &&
272      tokens >= minTokens
273    if (isDue) {
274      $.ui.toast(`Context at ${percent}%: writing a checkpoint, then compacting and resuming.`)
275      startCheckpoint($)
276    }
277
278    return result
279  })
280
281  // Any compaction of the main conversation (ours, /compact, auto) starts a
282  // fresh window, so the watch starts over; after ours, the session resumes.
283  on('session.compact', async ($, e, next) => {
284    const result = await next(e)
285    const isFreshWindow =
286      e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined
287    if (!isFreshWindow) {
288      return result
289    }
290
291    const wasOurs = session.phase === 'compacting'
292    reset($)
293    if (wasOurs) {
294      const { tokensBefore, tokensAfter } = result
295      $.ui.toast(
296        tokensBefore !== undefined && tokensAfter !== undefined
297          ? `Compacted early: ${tokensBefore.toLocaleString()} → ${tokensAfter.toLocaleString()} tokens.`
298          : 'Compacted early.',
299      )
300      startResume($, session.lastCheckpointPath)
301    }
302
303    return result
304  })
305}
306