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

hooks/register.ts 306 lines1import 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