Adds `/compact micro`: elides Read results and strips thinking, keeping the conversation structure

A Claude Mod that adds micro-compaction as /compact micro. Instead of asking a model to summarize your whole conversation, it trims the two bulkiest and cheapest-to-recover parts of the transcript and leaves everything else exactly as it was.
When you run /compact micro, the plugin rewrites the conversation transcript locally:
Read tool result is replaced with a short placeholder such as [file contents elided by compaction: <path>, N lines. Read it again if needed.] (or lines A-B for partial reads). Images and PDFs get one too ([image elided by compaction: <path>, WxH. …], [PDF elided …], [PDF pages elided …, pages N]), since their image and document blocks are usually the largest results in a conversation. Claude can read the file again on its own whenever it needs the contents.Some results are deliberately left alone: errored reads, text results already shorter than their placeholder ("unchanged since last read" stubs, tiny files), and earlier placeholders. Running /compact micro repeatedly is safe, because elision is idempotent.
/compact micro./compact, with or without instructions (/compact keep the plan), is passed through unchanged to Claude Code's normal model-written summary.Requires Claude Code v2.1.287 or later.
Install it from the official Anthropic plugin directory. In case you haven't added this marketplace yet, add it first, and refresh it to get the latest listing:
claude plugin marketplace add anthropic-plugin-directory
claude plugin marketplace update anthropic-plugin-directory
claude plugin install micro-compaction@anthropic-plugin-directory
If you prefer using the TUI, inside a session, use /plugin marketplace add, /plugin marketplace update and /plugin install with the same arguments.
To update to a newer release later:
claude plugin marketplace update anthropic-plugin-directory
claude plugin update micro-compaction@anthropic-plugin-directory
To try a local checkout while developing, load it directly instead:
claude --plugin-dir /path/to/micro-compaction
Then run /compact micro. Plain /compact keeps working as usual.
bashOutputMaxChars in your Claude Code config. Micro-compaction leaves Bash output alone, but long output is spilled to a file that Claude then reads with Read, and that read can be elided. The default threshold is 30000 characters; a lower one (for example 10000) spills more output to files, so more of it can be stripped./compact (the default) is a better fit, as is /clear. Micro-compaction keeps everything except file reads and thinking, so it can't discard irrelevant discussion.Read tool for file contents. Only Read results are elided. Output from cat in Bash, or from other tools such as MCP tools, web fetches and searches, is kept as is./compact for a summary, or rely on automatic compaction, which always uses the normal summary.The plugin registers exactly one hook, on the session.compact event, with the filter trigger: 'manual'. session.compact is also the name of the call that Claude Code makes to compact a conversation, so this hook sees the conversation's message list for every manual /compact. It changes the call only when the instructions are exactly micro (ignoring surrounding whitespace and case): then it returns a rewritten list (file reads elided, thinking dropped) in place of core's model-written summary. For any other /compact, with or without instructions, it calls next with the call unchanged, so Claude Code compacts as usual. Other triggers (automatic, plugin, precompute) never reach the hook because of its trigger: 'manual' filter. The plugin never makes the session.compact call itself, and it hooks no other event.
session.compact hook, written in TypeScript (hooks/register.ts and hooks/elide.ts), inside Claude Code.The core logic is the pure function microCompact(messages) in hooks/elide.ts, covered by unit tests in tests/elide.test.ts.
claude plugin validate .
claude plugin testhooks/register.ts 13 lines1import { microCompact } from './elide.ts'
2
3export function register(on: any) {
4 // Only the person's `/compact micro`; any other /compact (with or without instructions)
5 // and auto, plugin, precompute go to core's summary untouched
6 on('session.compact', { trigger: 'manual' }, async ($: any, e: any, next: any) => {
7 if (e.instructions?.trim().toLowerCase() !== 'micro') return next(e)
8 const { messages, stats } = microCompact(e.messages)
9 $.ui.log(`Elided ${stats.elided} file reads with ${stats.chars} chars`)
10 return { messages }
11 })
12}
13hooks/elide.ts 108 lines1import type { SessionMessage as Msg } from 'claude-code'
2
3export type MicroCompactStats = {
4 elided: number
5 /** Characters of Read result text replaced by placeholders */
6 chars: number
7 thinkingDropped: number
8}
9
10const MARKER = ' elided by compaction: '
11const AGAIN = '. Read it again if needed.]'
12// Any of our placeholders, so a second pass leaves them be
13const PLACEHOLDER = /^\[[^\]\n]* elided by compaction: /
14
15// Read numbers its output as ` 12\tline`; the first and last numbers give the range read
16const LINE_NO = /^\s*(\d+)\t/
17
18const pathOf = (input: Record<string, unknown>) => (typeof input.file_path === 'string' ? input.file_path : 'unknown file')
19
20function describeText(input: Record<string, unknown>, text: string): string {
21 let first: number | null = null
22 let last: number | null = null
23 for (const line of text.split('\n')) {
24 const m = LINE_NO.exec(line)
25 if (!m) continue
26 const n = Number(m[1])
27 first ??= n
28 last = n
29 }
30 const range = first === null ? '' : first === 1 ? `, ${last} lines` : `, lines ${first}-${last}`
31 return `[file contents${MARKER}${pathOf(input)}${range}${AGAIN}`
32}
33
34/**
35 * The placeholder for a Read whose bulk is an image or document block rather than text
36 * (`result.type` image, pdf, or parts: PDF pages rendered as images), or null for any other.
37 * Their `text` is empty or a one-line note, so length says nothing about their size.
38 */
39function describeMedia(input: Record<string, unknown>, result: unknown): string | null {
40 if (!result || typeof result !== 'object') return null
41 const r = result as { type?: unknown; file?: any; firstPage?: unknown }
42 const path = pathOf(input)
43 switch (r.type) {
44 case 'image': {
45 const d = r.file?.dimensions
46 const size = d?.originalWidth && d?.originalHeight ? `, ${d.originalWidth}x${d.originalHeight}` : ''
47 return `[image${MARKER}${path}${size}${AGAIN}`
48 }
49 case 'pdf':
50 return `[PDF${MARKER}${path}${AGAIN}`
51 case 'parts': {
52 const first = typeof r.firstPage === 'number' ? r.firstPage : null
53 const count = typeof r.file?.count === 'number' ? r.file.count : null
54 const pages =
55 typeof input.pages === 'string' ? input.pages : first !== null && count !== null ? `${first}-${first + count - 1}` : null
56 return `[PDF pages${MARKER}${path}${pages ? `, pages ${pages}` : ''}${AGAIN}`
57 }
58 default:
59 return null
60 }
61}
62
63// Thinking blocks come as assistant messages of their own (one transcript entry per block),
64// which read as no text and no tool uses
65function isThinkingOnly(m: Msg): boolean {
66 return m.role === 'assistant' && !m.text && m.toolUses.length === 0
67}
68
69/**
70 * Elides the results of Read calls and drops thinking. Every other message keeps its
71 * handle, so the engine keeps it whole; a user message holding an elided result is rebuilt.
72 */
73export function microCompact(messages: readonly Msg[]): { messages: Msg[]; stats: MicroCompactStats } {
74 const reads = new Map<string, Record<string, unknown>>()
75 for (const m of messages) for (const u of m.toolUses) if (u.tool === 'Read') reads.set(u.tool_use_id, u.input)
76
77 const stats: MicroCompactStats = { elided: 0, chars: 0, thinkingDropped: 0 }
78 const out: Msg[] = []
79 for (const m of messages) {
80 if (isThinkingOnly(m)) {
81 stats.thinkingDropped++
82 continue
83 }
84 let changed = false
85 const toolResults = m.toolResults?.map(r => {
86 const input = reads.get(r.tool_use_id)
87 if (!input || r.isError || PLACEHOLDER.test(r.text)) return r
88 let text = describeMedia(input, r.result)
89 if (text === null) {
90 text = describeText(input, r.text)
91 // Already short (an "unchanged since last read" stub, a tiny file)
92 if (r.text.length <= text.length) return r
93 }
94 changed = true
95 stats.elided++
96 stats.chars += r.text.length
97 return { tool_use_id: r.tool_use_id, isError: r.isError, text }
98 })
99 if (!changed) {
100 out.push(m)
101 continue
102 }
103 const { handle: _, ...rest } = m
104 out.push({ ...rest, toolResults })
105 }
106 return { messages: out, stats }
107}
108