SLOPSHOPPER

seamless-compaction

Cleans old tool results out of the context when the engine compacts, with no model call and no wait. Logs every operation.

newcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · seamless-compaction
› 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 › /seamless-compaction ⎿ seamless-compaction: seamless-compaction is on. When the engine compacts (window full), it cleans old tool results first, once the ⎿ seamless-compaction: No operation yet in this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

seamless-compaction

A Claude Code mod that cleans old tool results out of the context when the engine compacts. No model call and no wait. Like RTK, but after the output is already in context.

When the context window fills up, Claude Code normally asks the model to summarize the whole conversation. That costs a model call, takes time, and loses detail. Most of the bulk is usually old tool output: files read twice, the same git log run three times, long build logs. This mod removes that bulk first. If that frees enough room, the summary is skipped and your conversation stays as it was, minus the old noise.

Install

Claude Code marketplace (recommended)

This repo is a Claude Code plugin marketplace (chkp-roniz-plugins):

/plugin marketplace add chkp-roniz/harness
/plugin install seamless-compaction@chkp-roniz-plugins

Pick a scope when asked (user, project or local). From a shell you can do the same with:

claude plugin marketplace add chkp-roniz/harness
claude plugin install seamless-compaction@chkp-roniz-plugins

From a local folder

One session:

claude --plugin-dir /path/to/harness/plugins/seamless-compaction

Every session (set once in your shell profile):

export CLAUDE_CODE_PLUGIN_DIRS=/path/to/harness/plugins/seamless-compaction

microsoft/apm: not supported yet

Do not install this mod with apm install. This mod is a function-hook module (hooks/hooks.json lists register.ts), not a skill. APM (tested with 0.29.1) copies hooks.json into .claude/settings.json as a shell-command hook entry. That entry does not load the mod. Use the marketplace install above. The other plugins in this repo are skills and do install with APM.

How it works

The mod hooks the engine's session.compact event. It acts only on the engine's own compaction (trigger=auto, the window is full), in the main session or in a subagent. /compact, precompute and other plugins' compactions pass through untouched.

  1. Gate. In the main session it runs only once the context is at least startAt (70%) full. This is measured against the window the engine compacts at, so a smaller CLAUDE_CODE_AUTO_COMPACT_WINDOW is respected. Below that, the engine compacts as before. A subagent's compaction is not gated, because it only starts when that agent's window is full. If the fill cannot be measured, the cleanup runs.
  2. Clean. Old tool results are rewritten by these rules:
RuleWhat
readAn older Read of a path that is read, edited or written again later becomes a one-line stub.
dupAn older identical tool call + result becomes a stub. The newest stays.
noiseBash only: ANSI codes, \r progress lines and repeated lines (line (xN)).
trimResults over 4000 chars keep the first 40 and last 20 lines. Errors keep the first 2000 chars.

Every stub and cut says what was removed and how to get it back (re-read or re-run the tool).

  1. Decide. If the cleanup frees at least minAutoPct (30%) of the transcript, the cleaned messages replace the summary: no model call. If it frees less, the engine summarizes the cleaned messages as usual.
  2. Record. One log line, a toast in the main session, and the last 5 operations kept for /seamless-compaction.

Never touched: the last keepTurns (3) user prompts and everything after them, all message text, tool inputs, results under 300 chars (no stubs), and Agent / Task / TodoWrite results. The message count and every tool call/result pair stay the same. If anything fails, the compaction is left to the engine.

There is no cleanup between turns. The engine refuses a plugin's compaction while a turn runs. When it does run one, it skips the plugin's own session.compact hook. So the cleanup runs only when the engine compacts. (The experimental proactive option was removed in 0.1.3.)

Commands

/seamless-compaction shows the status and the last 5 operations. /seamless-compaction off and on switch it for the current session.

Options

OptionDefaultMeaning
startAt70Main-session context % before it cleans. 0 = every engine compaction.
keepTurns3The last N user prompts, and everything after them, are never touched.
minAutoPct30Minimum % freed to skip the engine's summary.

Set them in the plugin config menu, or in settings:

{ "pluginConfigs": { "seamless-compaction@chkp-roniz-plugins": { "options": { "keepTurns": 3 } } } }

With --plugin-dir, use the key seamless-compaction. A wrong key is ignored silently and every option stays at its default.

Log

One line per operation in ~/.claude/seamless-compaction.log (rotates to .log.1 above 1 MiB). It holds no conversation content.

2026-10-06T08:41:12Z session=<id> agent=main trigger=auto before=182.4k after=121.0k compaction=33.7% rules=read:4,dup:1,trim:9,noise:3
2026-10-06T09:02:40Z session=<id> agent=a1b2c3 trigger=auto before=150.2k after=139.8k compaction=6.9% result=engine-summary

Limits

  • A cleanup costs one prompt-cache miss on the next request.
  • /seamless-compaction on|off is per session.
  • A trimmed Read result can be re-read with offset and limit.

Development

claude plugin validate .
claude plugin test .

License

MIT, see the repository LICENSE.

Source 4 files
hooks/register.ts 130 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RecentOp } from '../types'
5import { cleanup, tokensOf } from './cleanup'
6import type { CleanupOptions, CleanupStats } from './cleanup'
7import { appendLine, formatLine } from './log'
8import type { LogIo, Op } from './log'
9
10const MAX_RECENT = 5
11const DEFAULT_START_AT = 70 // % of the context window; below it nothing is cleaned
12
13const disabled = atom({ plugin: 'seamless-compaction', key: 'disabled' } as const, false)
14const recent = atom({ plugin: 'seamless-compaction', key: 'recent' } as const, [] as RecentOp[])
15
16const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback)
17const k = (tokens: number) => (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens))
18const saved = (s: CleanupStats) => s.charsBefore - s.charsAfter
19
20const io = ($: EngineInterface): LogIo => ({
21  exists: p => $.fs.exists(p),
22  read: p => $.fs.read(p),
23  write: (p, t) => $.fs.write(p, t),
24})
25
26function line(r: RecentOp): string {
27  const t = new Date(r.at).toISOString().slice(11, 16)
28  return `${t} ${r.agent} ${r.trigger} ${r.pct.toFixed(1)}% ${r.result}`
29}
30
31/** One log line, the state list, and (main session only) a toast. A failure here never breaks a compaction. */
32async function record($: EngineInterface, agent: string, trigger: string, stats: CleanupStats, result: Op['result']) {
33  try {
34    const at = await $.clock.now()
35    const op: Op = { at, session: await $.session.id(), agent, trigger, stats, result }
36    const home = await $.env.get('HOME')
37    if (home) {
38      const failed = await appendLine(`${home}/.claude/seamless-compaction.log`, io($), formatLine(op))
39      if (failed) $.ui.log(`seamless-compaction: could not write the log (${failed})`)
40    }
41    await update($, recent, (list: RecentOp[]) =>
42      [{ at, agent, trigger, pct: stats.pct, result }, ...(list ?? [])].slice(0, MAX_RECENT),
43    )
44    if (result === 'cleanup' && agent === 'main') {
45      $.ui.toast(`Cleaned context: ${k(tokensOf(stats.charsBefore))} -> ${k(tokensOf(stats.charsAfter))} tokens (-${stats.pct.toFixed(1)}%)`)
46    }
47  } catch (err) {
48    $.ui.log(`seamless-compaction: could not record an operation (${String(err).slice(0, 120)})`)
49  }
50}
51
52/**
53 * How full the main context is, in % of the window the engine compacts at. That window is smaller than the model's
54 * when CLAUDE_CODE_AUTO_COMPACT_WINDOW or a setting lowers it, and `context.percent` always measures the model's.
55 * Undefined when nothing can be measured (a fresh session, an unreadable figure): that never blocks a cleanup.
56 */
57export async function fillPct($: EngineInterface): Promise<number | undefined> {
58  try {
59    const { context } = await $.session.usage({ breakdown: 'summary' })
60    const max = context.breakdown?.rawMaxTokens
61    const used = context.tokens ?? context.breakdown?.totalTokens
62    if (typeof max === 'number' && max > 0 && typeof used === 'number') return Math.round((used / max) * 1000) / 10
63    return context.percent
64  } catch {
65    return undefined
66  }
67}
68
69export const register: Register = (on, options) => {
70  const startAt = num(options.startAt, DEFAULT_START_AT)
71  const minAutoPct = num(options.minAutoPct, 30)
72  const clean: Partial<CleanupOptions> = { keepTurns: Math.max(1, num(options.keepTurns, 3)) }
73
74  // ------------------------------------------------------------ start
75
76  on('session.start', async ($, e, next) => {
77    await $.command.register({
78      name: 'seamless-compaction',
79      description: 'Cleanup of old tool results. "on" / "off" switch it, no argument shows the last operations.',
80      argumentHint: '[on | off]',
81    })
82    return next(e)
83  })
84
85  // ------------------------------------------------------------ the compaction itself
86
87  on('session.compact', async ($, e, next) => {
88    if (e.trigger !== 'auto') return next(e) // manual, precompute and other plugins' compactions pass through untouched
89    if (await read($, disabled)) return next(e)
90
91    try {
92      const agent = e.agentId ?? 'main'
93      // Below startAt the engine compacts as it always did. Only the main session has a figure to check:
94      // $.session.usage() is the main context, so a subagent's own compaction (it means that window is full) is not gated.
95      if (e.agentId === undefined && ((await fillPct($)) ?? Infinity) < startAt) return next(e)
96      const { messages, stats } = cleanup(e.messages ?? (await $.session.messages()), clean)
97      // The engine's own compaction (window full), main or subagent.
98      if (stats.pct >= minAutoPct) {
99        await record($, agent, e.trigger, stats, 'cleanup')
100        return { messages }
101      }
102      await record($, agent, e.trigger, stats, 'engine-summary')
103      return stats.changed > 0 ? next({ ...e, messages }) : next(e)
104    } catch (err) {
105      $.ui.log(`seamless-compaction: left to the engine (${String(err).slice(0, 120)})`)
106      return next(e)
107    }
108  })
109
110  // ------------------------------------------------------------ /seamless-compaction
111
112  on('command.run', { command: 'seamless-compaction' }, async ($, e) => {
113    const arg = e.args.trim().toLowerCase()
114    if (arg === 'off' || arg === 'on') {
115      await update($, disabled, () => arg === 'off')
116      return { text: arg === 'off' ? 'seamless-compaction is off for this session only. The engine compacts as before.' : 'seamless-compaction is on for this session.' }
117    }
118    const list = (await read($, recent)) ?? []
119    const state = (await read($, disabled)) ? 'off for this session' : 'on'
120    const when = startAt > 0 ? `once the compaction window is ${startAt}% full` : 'whenever the engine compacts'
121    return {
122      text: [
123        ...(arg ? [`Unknown argument "${arg.slice(0, 40)}". Use: /seamless-compaction [on | off].`] : []),
124        `seamless-compaction is ${state}. When the engine compacts (window full), it cleans old tool results first, ${when}${startAt > 0 ? '; below that the engine summarizes as before' : ''}.`,
125        ...(list.length ? list.map(line) : ['No operation yet in this session.']),
126      ].join('\n'),
127    }
128  })
129}
130
hooks/cleanup.ts 183 lines
1import type { SessionMessage } from 'claude-code'
2
3/** Light cleanup of old tool results. Pure: no engine calls, so it can be tested alone. */
4
5export type Rule = 'read' | 'dup' | 'trim' | 'noise'
6
7export type CleanupOptions = {
8  /** The last N user prompts, and everything after the first of them, are never changed. */
9  keepTurns: number
10  /** Results longer than this many characters are trimmed (rule "trim"). */
11  longChars: number
12}
13
14export type CleanupStats = {
15  charsBefore: number
16  charsAfter: number
17  /** Share of the transcript that was cleaned away, 0 to 100, one decimal. */
18  pct: number
19  /** Results changed per rule. */
20  rules: Record<Rule, number>
21  /** Messages that were rebuilt (they lose the engine's handle). */
22  changed: number
23}
24
25export const DEFAULTS: CleanupOptions = { keepTurns: 3, longChars: 4000 }
26
27const PROTECTED_TOOLS = new Set(['Agent', 'Task', 'TodoWrite'])
28const MIN_STUB_CHARS = 300 // stubbing a smaller result saves nothing
29const HEAD_LINES = 40
30const TAIL_LINES = 20
31const ERROR_KEEP = 2000
32const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
33
34type ToolResult = NonNullable<SessionMessage['toolResults']>[number]
35type Use = { tool: string; input: Record<string, unknown> }
36
37export const isPrompt = (m: SessionMessage) => m.role === 'user' && (m.toolResults?.length ?? 0) === 0 && m.text.trim() !== ''
38
39export function sizeOf(m: SessionMessage): number {
40  let n = m.text.length
41  for (const u of m.toolUses ?? []) n += JSON.stringify(u.input ?? {}).length + u.tool.length
42  for (const r of m.toolResults ?? []) n += r.text.length
43  return n
44}
45
46/** Index of the first protected message: the start of the keepTurns-th last user prompt. */
47export function protectedFrom(messages: readonly SessionMessage[], keepTurns: number): number {
48  let seen = 0
49  for (let i = messages.length - 1; i >= 0; i--) {
50    const m = messages[i]
51    if (!m || !isPrompt(m)) continue
52    if (++seen >= keepTurns) return i
53  }
54  return 0 // fewer prompts than keepTurns: everything is protected
55}
56
57const pathOf = (input: Record<string, unknown>): string | undefined => {
58  const p = input.file_path ?? input.notebook_path ?? input.path
59  return typeof p === 'string' ? p : undefined
60}
61
62function noise(text: string): string {
63  const lines = text.replace(ANSI, '').split('\n').map(l => (l.includes('\r') ? (l.split('\r').filter(Boolean).pop() ?? '') : l))
64  const out: string[] = []
65  for (let i = 0; i < lines.length; ) {
66    let j = i + 1
67    while (j < lines.length && lines[j] === lines[i]) j++
68    const line = lines[i] ?? ''
69    out.push(j - i > 2 && line.trim() !== '' ? `${line} (x${j - i})` : lines.slice(i, j).join('\n'))
70    i = j
71  }
72  return out.join('\n')
73}
74
75function trim(text: string, isError: boolean, longChars: number): string | undefined {
76  if (isError) {
77    if (text.length <= ERROR_KEEP + 200) return undefined
78    return `${text.slice(0, ERROR_KEEP)}\n[... ${text.length - ERROR_KEEP} chars of this error trimmed by seamless-compaction; re-run the tool for the full output]`
79  }
80  if (text.length <= longChars) return undefined
81  const lines = text.split('\n')
82  if (lines.length > HEAD_LINES + TAIL_LINES + 5) {
83    const cut = lines.length - HEAD_LINES - TAIL_LINES
84    const dropped = lines.slice(HEAD_LINES, lines.length - TAIL_LINES).join('\n').length
85    return [...lines.slice(0, HEAD_LINES), `[... ${cut} lines / ${dropped} chars trimmed by seamless-compaction; re-run the tool for the full output]`, ...lines.slice(-TAIL_LINES)].join('\n')
86  }
87  // Few but very long lines: cut by characters.
88  const head = Math.floor(longChars / 2)
89  const tail = Math.floor(longChars / 4)
90  return `${text.slice(0, head)}\n[... ${text.length - head - tail} chars trimmed by seamless-compaction; re-run the tool for the full output]\n${text.slice(-tail)}`
91}
92
93export function cleanup(messages: readonly SessionMessage[], options: Partial<CleanupOptions> = {}): { messages: SessionMessage[]; stats: CleanupStats } {
94  const o = { ...DEFAULTS, ...options }
95  const limit = protectedFrom(messages, o.keepTurns)
96
97  // The call each result answers, and where later reads/edits of a path happen.
98  const uses = new Map<string, Use>()
99  const touched = new Map<string, number>() // path -> index of the last Read/Edit/Write of it
100  const lastSame = new Map<string, number>() // tool+input+text -> index of the newest identical result
101  messages.forEach((m, i) => {
102    for (const u of m.toolUses ?? []) {
103      uses.set(u.tool_use_id, { tool: u.tool, input: u.input ?? {} })
104      const p = pathOf(u.input ?? {})
105      if (p && ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(u.tool)) touched.set(p, i)
106    }
107  })
108  const sameKey = (r: ToolResult) => {
109    const u = uses.get(r.tool_use_id)
110    return u ? `${u.tool}\u0000${JSON.stringify(u.input)}\u0000${r.text}` : undefined
111  }
112  messages.forEach((m, i) => {
113    for (const r of m.toolResults ?? []) {
114      const k = sameKey(r)
115      if (k && !r.isError) lastSame.set(k, i)
116    }
117  })
118
119  const rules: Record<Rule, number> = { read: 0, dup: 0, trim: 0, noise: 0 }
120  let changed = 0
121  let charsBefore = 0
122  let charsAfter = 0
123
124  const out = messages.map((m, i) => {
125    const before = sizeOf(m)
126    charsBefore += before
127    if (i >= limit || (m.toolResults?.length ?? 0) === 0) {
128      charsAfter += before
129      return m
130    }
131    let touchedMessage = false
132    const results = m.toolResults!.map(r => {
133      const use = uses.get(r.tool_use_id)
134      if (!use || PROTECTED_TOOLS.has(use.tool) || r.text.length === 0) return r
135      let text = r.text
136      const stub = (rule: Rule, why: string) => {
137        rules[rule]++
138        return `[seamless-compaction: ${why}]`
139      }
140      const path = pathOf(use.input)
141      if (!r.isError && text.length >= MIN_STUB_CHARS) {
142        if (use.tool === 'Read' && path && (touched.get(path) ?? -1) > i) text = stub('read', `older read of ${path}; a later read or edit of it exists`)
143        else {
144          const k = sameKey(r)
145          if (k && (lastSame.get(k) ?? i) > i) text = stub('dup', 'identical result of an identical later call; see the later one')
146        }
147      }
148      if (text === r.text) {
149        if (use.tool === 'Bash') {
150          const clean = noise(text)
151          if (text.length - clean.length >= 100) {
152            text = clean
153            rules.noise++
154          }
155        }
156        const t = trim(text, r.isError, o.longChars)
157        if (t !== undefined) {
158          text = t
159          rules.trim++
160        }
161      }
162      if (text === r.text) return r
163      touchedMessage = true
164      return { ...r, text, result: undefined }
165    })
166    if (!touchedMessage) {
167      charsAfter += before
168      return m
169    }
170    changed++
171    // Rebuilt from its parts: no handle, so the engine reads it as built here.
172    const rebuilt: SessionMessage = { role: m.role, text: m.text, toolUses: m.toolUses, toolResults: results }
173    charsAfter += sizeOf(rebuilt)
174    return rebuilt
175  })
176
177  const saved = charsBefore - charsAfter
178  const pct = charsBefore > 0 ? Math.round((saved / charsBefore) * 1000) / 10 : 0
179  return { messages: out, stats: { charsBefore, charsAfter, pct, rules, changed } }
180}
181
182export const tokensOf = (chars: number) => Math.ceil(chars / 4)
183
hooks/log.ts 64 lines
1import type { CleanupStats } from './cleanup'
2import { tokensOf } from './cleanup'
3
4/** One line per operation in ~/.claude/seamless-compaction.log. Never any conversation content. */
5
6export type Op = {
7  at: number
8  session: string
9  /** 'main' or the subagent's id. */
10  agent: string
11  trigger: string
12  stats: CleanupStats
13  /** What happened to the compaction: 'cleanup' (ours answered), 'engine-summary' (too little to clean, engine went on), 'skipped'. */
14  result: 'cleanup' | 'engine-summary' | 'skipped'
15}
16
17const MAX_BYTES = 1024 * 1024
18
19const k = (tokens: number) => (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens))
20
21export function formatLine(op: Op): string {
22  const s = op.stats
23  const rules = (Object.entries(s.rules) as [string, number][]).filter(([, n]) => n > 0).map(([r, n]) => `${r}:${n}`).join(',')
24  return [
25    new Date(op.at).toISOString().replace(/\.\d+Z$/, 'Z'),
26    `session=${op.session}`,
27    `agent=${op.agent}`,
28    `trigger=${op.trigger}`,
29    `before=${k(tokensOf(s.charsBefore))}`,
30    `after=${k(tokensOf(s.charsAfter))}`,
31    `compaction=${s.pct.toFixed(1)}%`,
32    op.result === 'cleanup' ? `rules=${rules || 'none'}` : `result=${op.result}`,
33  ].join(' ')
34}
35
36/** The three file calls the log needs, so this file never holds the engine's `$`. */
37export type LogIo = {
38  exists: (path: string) => Promise<boolean>
39  read: (path: string) => Promise<string>
40  write: (path: string, text: string) => Promise<void>
41}
42
43let queue: Promise<void> = Promise.resolve()
44
45/** Appends one line. Calls are serialized so two writes never lose each other's line. Failures are returned, never thrown. */
46export function appendLine(path: string, io: LogIo, line: string): Promise<string | undefined> {
47  const run = async (): Promise<string | undefined> => {
48    try {
49      let text = (await io.exists(path)) ? await io.read(path) : ''
50      if (text.length > MAX_BYTES) {
51        await io.write(`${path}.1`, text)
52        text = ''
53      }
54      await io.write(path, `${text}${line}\n`)
55      return undefined
56    } catch (err) {
57      return String(err).slice(0, 120)
58    }
59  }
60  const result = queue.then(run)
61  queue = result.then(() => undefined)
62  return result
63}
64
types/index.d.ts 21 lines
1export type RecentOp = {
2  /** Milliseconds since the epoch. */
3  at: number
4  agent: string
5  trigger: string
6  /** Share cleaned away, 0 to 100. */
7  pct: number
8  result: 'cleanup' | 'engine-summary' | 'skipped'
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'seamless-compaction': {
14      /** Turned off with `/seamless-compaction off`. */
15      disabled: boolean
16      /** Newest first. */
17      recent: RecentOp[]
18    }
19  }
20}
21