SLOPSHOPPER

compact-keeper

Saves each compaction's summary, with the files edited before it, to ~/.claude/handoffs so the context before /compact can be recovered

newguardtoast
★ 5v0.1.1MITupdated 2026-10-02arasovic/claude-code-mods/compact-keeper
A shopper browsing a rack in a slop shop
README

compact-keeper

Saves a handoff file each time the conversation is compacted, so the context before /compact can be recovered.

What it writes

One Markdown file per compaction in ~/.claude/handoffs/, named <date>-<time>-<project>-<session>.md:

  • when it ran, manual or automatic, and the token count before and after
  • the working directory and the session id, with the claude --resume command
  • any instructions given to /compact
  • the files edited since the previous compaction
  • the summary Claude Code wrote

A short toast names the file once it is saved. Subagent compactions are skipped, and a failed write never blocks the compaction.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install compact-keeper@claude-code-mods

Restart Claude Code. Nothing to open: the next compaction writes the first file.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh compact-keeper
Source 2 files
hooks/register.ts 75 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMessage } from 'claude-code'
3
4// Paths edited since the last compaction, listed in the next handoff.
5const edited = atom({ plugin: 'compact-keeper', key: 'edited' } as const, [] as string[])
6
7const pad = (n: number) => String(n).padStart(2, '0')
8const stamp = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
9
10export const fileName = (at: number, cwd: string, sessionId: string) => {
11  const d = new Date(at)
12  const project = (cwd.split('/').filter(Boolean).pop() ?? 'root').replace(/[^\w.-]+/g, '-')
13  return `${stamp(d)}-${pad(d.getHours())}${pad(d.getMinutes())}-${project}-${sessionId.slice(0, 8)}.md`
14}
15
16// The summary is the message core wrote: the one carrying no handle from the transcript it replaced.
17export const summaryOf = (before: readonly SessionMessage[], after: readonly SessionMessage[]) => {
18  const kept = new Set(before.map(m => m.handle).filter(Boolean))
19  return (after.find(m => m.text && !(m.handle && kept.has(m.handle))) ?? after[0])?.text ?? ''
20}
21
22export const handoff = (h: { at: number; cwd: string; sessionId: string; trigger: string; instructions?: string; tokensBefore?: number; tokensAfter?: number; files: readonly string[]; summary: string }) => {
23  const d = new Date(h.at)
24  const size = h.tokensBefore === undefined ? '' : `, ${Math.round(h.tokensBefore / 1000)}k → ${h.tokensAfter === undefined ? '?' : Math.round(h.tokensAfter / 1000)}k tokens`
25  return [
26    `# Handoff: ${h.cwd.split('/').filter(Boolean).pop() ?? h.cwd}`,
27    '',
28    // toTimeString starts with the local HH:MM:SS.
29    `- Compacted: ${stamp(d)} ${d.toTimeString().slice(0, 5)} (${h.trigger})${size}`,
30    `- Directory: ${h.cwd}`,
31    `- Session: ${h.sessionId} (\`claude --resume ${h.sessionId}\`)`,
32    ...(h.instructions ? [`- Instructions: ${h.instructions}`] : []),
33    '',
34    '## Files edited before this compaction',
35    '',
36    ...(h.files.length ? h.files.map(f => `- ${f}`) : ['- none']),
37    '',
38    '## Summary',
39    '',
40    h.summary.trim(),
41    '',
42  ].join('\n')
43}
44
45export const register: Register = on => {
46  on('tool.call', async ($, e, next) => {
47    const result = await next(e)
48    if ((e.tool === 'Edit' || e.tool === 'Write') && result.deny === undefined && !result.isError) {
49      const path = (e as unknown as { file_path: string }).file_path
50      await update($, edited, list => [path, ...list.filter(p => p !== path)].slice(0, 100))
51    }
52    return result
53  })
54
55  on('session.compact', async ($, e, next) => {
56    const result = await next(e)
57    // A precompute may never be installed, and a subagent's compaction is not the conversation's.
58    if (result.skip !== undefined || e.trigger === 'precompute' || e.agentId !== undefined) return result
59    try {
60      const at = await $.clock.now()
61      const cwd = await $.session.cwd()
62      const sessionId = await $.session.id()
63      const home = await $.env.get('HOME')
64      const files = await read($, edited)
65      const path = `${home ?? '.'}/.claude/handoffs/${fileName(at, cwd, sessionId)}`
66      await $.fs.write(path, handoff({ at, cwd, sessionId, trigger: e.trigger, instructions: e.instructions, tokensBefore: result.tokensBefore, tokensAfter: result.tokensAfter, files, summary: summaryOf(e.messages, result.messages) }))
67      await update($, edited, () => [])
68      $.ui.toast(`Handoff saved: ${path.replace(home ?? '\0', '~')}`)
69    } catch {
70      // Saving is a convenience: a failed write must never undo the compaction.
71    }
72    return result
73  })
74}
75
types/index.d.ts 8 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'compact-keeper': {
4      edited: string[]
5    }
6  }
7}
8