Compacts an idle session before its prompt cache expires: first by masking old tool results and dropping thinking with no model call, falling back to a summary…

idle-micro-compact compacts an idle Claude Code conversation before its 1-hour prompt cache expires. It masks old tool output, drops thinking, or asks Claude Code to write a summary. This can lower the cache-write cost when you return.
One idle 150k-token conversation on Claude Opus 5.5, from the idle pause to your first message back:
| What happens | Tokens re-cached on return | Total cost | Saved with the plugin |
|---|---|---|---|
| Without the plugin: nothing | 150k | $1.20 | — |
Without the plugin: /compact after the cache expired | 10k | $1.48 | −$0.28 |
| With the plugin: summary before the cache expires | 10k | $0.31 | $0.89 (74%) |
| With the plugin: old tool results masked | 50k | $0.40 | $0.80 (67%) |
"Saved with the plugin" is the difference from the first row. The total cost is the compaction request plus the first request when you return:
/compact on a cold cache writes all 150k tokens ($1.20) and 10k of summary output ($0.20).Assumptions:
Compacting cold pays to send the whole conversation once just to shrink it. Compacting while warm reads it from cache at 5% of the base input price. These figures are an example. Actual savings depend on the conversation size.
claude plugin marketplace add kylesnowschwartz/idle-micro-compact
claude plugin install idle-micro-compact@idle-micro-compact
Run /reload-plugins in each open Claude Code session after installation.
The mod registers hooks for session and turn events. It reads conversation usage and messages, stores the last turn time in Claude Code's plugin state, runs the built-in /compact command, and writes a transcript log line and status line. It adds one command, /micro-compact.
The defaults work with a 1-hour prompt cache. Configure the options inside Claude Code:
/plugin configure idle-micro-compact
Or configure them from a shell:
claude plugin configure idle-micro-compact
To edit the options by hand, add them to your user settings at ~/.claude/settings.json. Claude Code reads plugin options from user settings, not from a project's .claude/settings.json.
{
"pluginConfigs": {
"idle-micro-compact@idle-micro-compact": {
"options": {
"idleMinutes": 50,
"minTokens": 40000,
"microTargetTokens": 150000,
"minSavedShare": 0.1
}
}
}
}
| Option | Default | Meaning |
|---|---|---|
idleMinutes | 50 | Minutes idle before compacting. 0 also uses the 50-minute default. |
minTokens | 40000 | Leave smaller conversations alone. This counts the Messages row, not the system prompt or tools. |
microTargetTokens | 150000 | Use masking with no model call when the estimated result is at or below this size. |
minSavedShare | 0.1 | Leave the conversation alone when masking would save less than this share of it, from 0 to 1. 0.1 is 10%. |
Set idleMinutes below 60. A value of 60 or more leaves no warm-cache window, so the mod skips compaction when the timer fires.
After each main-session turn, the mod starts an idle timer. A new turn, /clear, or session exit cancels it. Reloading the plugin restarts the timer with the time left since the last turn.
When the timer fires, the mod follows these steps:
/context. If that row is unavailable, it uses the whole context. If the size is unknown, it leaves the session alone.minTokens./compact. Its compaction hook masks old tool results and drops thinking, then estimates the remaining conversation size.microTargetTokens, Claude Code writes a summary.minSavedShare of the conversation, or would change nothing, it leaves the conversation alone: no masking, no summary, and no model call. Masking rewrites the transcript, so your next request writes all of it to the cache again. A small saving is not worth that.The automatic request appears in the transcript as a manual /compact line.
Run /micro-compact to trim now instead of waiting for the timer:
minTokens, microTargetTokens, minSavedShare, and the cache deadline. It trims however large the conversation is and however little the trim saves./compact idle-micro-compact: trim on request.trimmed at 14:52 by /micro-compact: nothing left to remove (old tool results are already placeholders or among the 5 newest).| Result | What stays |
|---|---|
Read text | A shorter one-line placeholder with the file and line range. |
| Images and PDFs | A placeholder with the file name. |
Bash, Grep, Glob, WebFetch output | The first and last 15 lines when shorter. Otherwise, a placeholder. Each end has a 1,500-character cap. |
| Other tool results | A placeholder naming the file, search, URL, command, or MCP call. File changes say to read the file. This includes WebSearch, Edit, Write, MultiEdit, and MCP tools. |
| Errors | The first 400 characters when shorter. Other errors stay whole. |
| Thinking blocks | Nothing. They are dropped. |
The 5 newest calls to these tools stay whole. Todo and subagent calls do not count toward the 5. Text results of 200 characters or less stay whole. Masked output must be read or rerun to recover it. Placeholders and markers list up to 50 paths from removed text. A later pass leaves them unchanged.
Across 50 idle points from 44 of the author's real Claude Code sessions, this method removed a median 54% of the conversation, against 39% for the upstream method of masking only Read results and dropping thinking. It lost none of the file paths used in the next three turns. This was an offline benchmark on one person's sessions. It checked paths by string match.
After a successful compaction, the mod writes a line to the transcript and pins a shorter one as the status. The next turn clears the status. Examples:
● idle-micro-compact: trimmed at 23:00 after 50 min idle: 175k → ~73k tokens (21 old tool results removed, 8 shortened, 66 thinking blocks dropped)
● idle-micro-compact: summarized at 18:41 after 50 min idle: 285k → 19k summary
● idle-micro-compact: trimmed at 14:50 by /micro-compact: 175k → ~73k tokens (21 old tool results removed, 8 shortened, 66 thinking blocks dropped)
A line from /micro-compact says by /micro-compact where the timer's says after 50 min idle.
The status for the first two compactions:
trimmed at 23:00: 175k → ~73k tokens
summarized at 18:41: 285k → 19k summary
shortened counts tool results cut to their head and tail. Errors cut to their start are listed only when there are some.summary chosen: ..., for tuning the mod. It holds why trimming wasn't enough (trim estimate 200k, target 150k) and the share of the summary request input read from cache (summary request 99% cached). Each part is left out when unknown, and the line is omitted when both are.not trimmed at appears when masking would save less than minSavedShare, as in not trimmed at 11:04 after 1 min idle: a trim would save ~0 of 44k tokens, under the 10% minimum. It does not set a status.not compacted: appears for a known-small or late conversation, or a failed compaction. It does not set a status. not compacted by /micro-compact: gives the error that stopped a /micro-compact trim.not compacted: the compaction was cancelled appears when you press Esc while the compaction runs. When the mod can't tell why a compaction stopped, it writes not compacted at 14:52: the compaction stopped before the mod decided (cancelled?).The timer writes no line if the size is unknown, a turn overtakes it, or Claude Code skips compaction.
microTargetTokens may get a summary when trimming would have been enough.CLAUDE.md files sent again on the next Read in that directory. Queued user messages and subagent reports received during a turn are lost. Claude's replies to them remain, but their original text does not.This mod combines work from two projects:
hooks/register.ts and tests/register.test.ts are adapted from it.microCompact behavior for replacing Read results and dropping thinking. hooks/mask.ts and tests/mask.test.ts are derived from it.This mod extends microCompact. It masks every common tool, not only Read. It keeps the 5 newest covered tool uses whole. It keeps the head and tail of command output and the start of errors. Its placeholders list file paths from removed text.
claude plugin validate . # show what the mod hooks and calls
claude plugin test . # run the mocked clock, usage, compaction, and summary tests
npx -p typescript tsc -p . # type-check after Claude Code writes the generated types
Claude Code writes .claude-plugin/types/ the first time it loads the mod from this folder. The directory is ignored by Git. The root tsconfig.json extends the generated tsconfig.json there.
Use scripts/try-in-tmux to open a throwaway Claude Code session in a new tmux window:
scripts/try-in-tmux micro # expect `trimmed at`
scripts/try-in-tmux summary # expect `summarized at`
Inside tmux, the script opens the window in the current session. Outside tmux, it uses a detached session named idle-micro. It writes three large files and five one-line notes to ${TMPDIR:-/tmp}/idle-micro-compact-try/, then starts Claude Code there on Haiku with the mod loaded, a one-minute idle delay, and a 100-token floor. It prints the prompt to paste and the expected line.
Pass --drive to accept the fixture folder's trust prompt, send the test prompt, wait for the idle compaction line, and exit successfully only when the expected branch appears. The tmux window stays open.
MIT. See LICENSE for this mod's notice and both upstream notices.
hooks/register.ts 534 lines1import type {
2 EngineInterface, Next, PluginOptions, Register, SessionCompactInput, SessionCompactResult, SessionContextUsage, SessionMessage, Timer,
3} from 'claude-code'
4import { chooseBranch, estimateAfter, removedAnything, thinkingTokensIn } from './decide.ts'
5import { type MaskStats, maskOldToolResults } from './mask.ts'
6import { type Outcome, type Trim, type When, approx, leftAloneLine, microLine, nothingLeftLine, summaryLine } from './lines.ts'
7
8const MINUTE_MS = 60_000
9// The prompt cache's lifetime from the last request; past it the cache is cold.
10const HOUR_MS = 60 * MINUTE_MS
11// The default lead before the cache lapses, leaving a summary pass time to finish.
12const MARGIN_MS = 10 * MINUTE_MS
13// Below this many conversation tokens a cold re-read is cheap; not worth losing detail. The system
14// prompt and tools don't count: compaction leaves them in place, and they are re-cached either way.
15const DEFAULT_MIN_TOKENS = 40_000
16// At or under this many conversation tokens after masking old tool results, the masked transcript
17// is small enough to come back to, and no summary is written.
18const DEFAULT_MICRO_TARGET_TOKENS = 150_000
19// A trim that saves less than this share of the conversation is not worth rewriting the
20// transcript: the next request writes all of it to the cache again.
21const DEFAULT_MIN_SAVED_SHARE = 0.1
22
23// How the instructions of the timer's /compact open. Its session.compact hook looks for this opening
24// to tell the timer's call apart from a person's /compact or another plugin's. The rest of the
25// instructions guide the summary, so a summary written without the hook's help (the hook was
26// skipped, or chose the summary) gets the same guidance.
27const OWN_OPENING = 'This idle compaction ran '
28
29// The whole of the arguments of /micro-compact's /compact, shown on the `/compact` row it leaves.
30// The session.compact hook trims on seeing them and never hands the call to the summary, so they
31// carry no summary guidance.
32const ON_REQUEST = 'idle-micro-compact: trim on request'
33
34// The slash command that trims at once. Spelled out where it is registered and
35// hooked: `claude plugin validate` recognizes a hook answering its own command by a literal name only.
36const COMMAND = 'micro-compact'
37
38// When a session's last main-loop turn ended (declared in types/index.d.ts). Held by the host for the
39// process, so it outlives a reload of this module.
40const LAST_TURN = { plugin: 'idle-micro-compact', key: 'lastTurn' } as const
41
42/** The plugin options, read once: changing them reloads the module. */
43type Settings = {
44 // idle time before compacting
45 idleMs: number
46 // the conversation size below which compacting isn't worth it
47 minTokens: number
48 // the conversation size at or under which masking old tool results is enough
49 microTargetTokens: number
50 // the share of the conversation a trim must save, from 0 to 1
51 minSavedShare: number
52}
53
54/** When the idle time began, and the last moment the cache is still warm enough to compact. */
55type IdleWindow = { since: number; latest: number }
56
57/** The sizes just before compacting: the whole context, and the conversation within it. */
58type SizesBefore = { contextTokens: number; conversationTokens: number }
59
60/** The conversation's size as measured just before asking for this module's compaction; absent when unknown. */
61type Pending = { conversationTokens: number | undefined }
62
63/**
64 * What the session.compact hook decided on this module's own compaction, for the line written after
65 * it: the timer's outcome, or core's summary after the timer's hook failed; on /micro-compact's run
66 * a trim, nothing to take out, or a failure.
67 */
68type Decision =
69 | { kind: 'timer'; outcome: Outcome }
70 | { kind: 'summary-after-failure'; summary: SessionCompactResult; message: string }
71 | { kind: 'trimmed'; trim: Trim }
72 | { kind: 'nothing-left' }
73 | { kind: 'failed'; message: string }
74 | { kind: 'cancelled' }
75
76/** Per-activation state: the armed timer and what the module has put on screen. */
77type State = {
78 timer?: Timer
79 // Bumped on every disarm, so an arm still awaiting its reads can tell it was overtaken.
80 generation: number
81 hasStatus: boolean
82 // What was measured just before this module's compaction, for the session.compact hook.
83 pending?: Pending
84 // Set by the session.compact hook during this module's own compaction, read after it for the report.
85 decision?: Decision
86}
87
88function nonNegative(raw: unknown): number | undefined {
89 if (raw === undefined || raw === '') return undefined
90 const n = Number(raw)
91 return Number.isFinite(n) && n >= 0 ? n : undefined
92}
93
94function shareOf(raw: unknown): number | undefined {
95 if (typeof raw !== 'number' && typeof raw !== 'string') return undefined
96 const n = nonNegative(raw)
97 return n !== undefined && n <= 1 ? n : undefined
98}
99
100/**
101 * The idle time assumes the 1-hour cache: the mod is no use on a 5-minute one. A value out of its
102 * range, or not a number, is its default: the host checks options against plugin.json, but a value
103 * that reaches the module unchecked must not set a share over 1 or a negative size.
104 */
105export function readSettings(options: PluginOptions): Settings {
106 const idleMinutes = nonNegative(options.idleMinutes)
107 return {
108 idleMs: idleMinutes ? idleMinutes * MINUTE_MS : HOUR_MS - MARGIN_MS,
109 minTokens: nonNegative(options.minTokens) ?? DEFAULT_MIN_TOKENS,
110 microTargetTokens: nonNegative(options.microTargetTokens) ?? DEFAULT_MICRO_TARGET_TOKENS,
111 minSavedShare: shareOf(options.minSavedShare) ?? DEFAULT_MIN_SAVED_SHARE,
112 }
113}
114
115const isTimerRun = (e: SessionCompactInput) => e.agentId === undefined && e.instructions?.startsWith(OWN_OPENING) === true
116
117const isRequestRun = (e: SessionCompactInput) => e.agentId === undefined && e.instructions?.trim() === ON_REQUEST
118
119const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
120
121/**
122 * What compaction can shrink: the conversation, as /context's Messages row counts it. The whole
123 * context when there is no breakdown.
124 *
125 * The row is a residual: the last response's input less the other rows. That needs the `full`
126 * breakdown, which counts the other rows with the token-count API. The `summary` breakdown
127 * estimates them and can be badly off, leaving next to nothing for the conversation.
128 */
129function conversationTokensOf(context: SessionContextUsage) {
130 const row = context.breakdown?.categories.find((c) => c.kind === 'used' && c.name === 'Messages')
131 return row?.tokens ?? context.tokens
132}
133
134/** When the timer's compaction ran. */
135type IdleWhen = Extract<When, { idleMin: number }>
136
137const instructions = (when: IdleWhen) =>
138 OWN_OPENING +
139 `automatically at ${when.at}, after ${when.idleMin} minutes idle, before the prompt cache expired. ` +
140 'Open the summary by saying so. Keep open tasks, decisions and their reasons, pending follow-ups with dates, ' +
141 'file paths touched, and anything the user asked to be remembered.'
142
143function disarm(state: State) {
144 state.generation++
145 state.timer?.cancel()
146 state.timer = undefined
147}
148
149/** A check for whether a disarm has come since this call. */
150function overtakenSince(state: State) {
151 const generation = state.generation
152 return () => generation !== state.generation
153}
154
155/** A reload while a turn runs, or after the session ended, must not re-arm. */
156function forget($: EngineInterface) {
157 return $.state.set(LAST_TURN, null).catch(() => {})
158}
159
160function clearStatus($: EngineInterface, state: State) {
161 if (state.hasStatus) $.ui.status(undefined)
162 state.hasStatus = false
163}
164
165/** What the session.compact hook decided, cleared so the next compaction starts without it. */
166function takeDecision(state: State) {
167 const { decision } = state
168 state.decision = undefined
169 return decision
170}
171
172/** Pins the status line, unless a turn started meanwhile and cleared it already. */
173function pinStatus($: EngineInterface, state: State, status: string, isOvertaken: () => boolean) {
174 if (isOvertaken()) return
175 $.ui.status(status)
176 state.hasStatus = true
177}
178
179/** The sizes just before compacting; undefined while the context size is unknown. */
180async function measureBefore($: EngineInterface): Promise<SizesBefore | undefined> {
181 const { context } = await $.session.usage({ breakdown: 'full' })
182 // Unknown after a compaction until the next response: nothing to shrink then either.
183 if (context.tokens === undefined) return undefined
184 return { contextTokens: context.tokens, conversationTokens: conversationTokensOf(context) ?? context.tokens }
185}
186
187/**
188 * The thinking the conversation sends back, in tokens. 0 when the API view can't be read: the
189 * estimate then takes no thinking off.
190 */
191async function sentBackThinkingTokens($: EngineInterface) {
192 try {
193 return thinkingTokensIn(await $.session.messages({ as: 'api' }))
194 } catch {
195 return 0
196 }
197}
198
199type EstimateFor = { conversationTokens: number; original: readonly SessionMessage[]; compacted: readonly SessionMessage[]; stats: MaskStats }
200
201/** The conversation's estimated size once masked, thinking and masked media included. */
202async function estimateFor($: EngineInterface, { conversationTokens, original, compacted, stats }: EstimateFor) {
203 // Dropping the thinking-only messages drops all the thinking the API view holds; with none dropped, none counts.
204 const droppedThinkingTokens = stats.thinkingDropped ? await sentBackThinkingTokens($) : 0
205 return estimateAfter({ conversationTokens, original, compacted, droppedThinkingTokens, maskedMediaTokens: stats.mediaTokens })
206}
207
208type Compaction = { e: SessionCompactInput; next: Next<'session.compact'>; conversationTokens: number; state: State; settings: Settings }
209
210/**
211 * Masks when that is estimated to land under the target and save enough, answering with no model
212 * call; skips the compaction, leaving the conversation as it is, when the trim would save too
213 * little; hands the call down to core's summary when the trim would land over the target.
214 * `conversationTokens` is the Messages row just before the call: the event carries the transcript
215 * but not its size.
216 */
217async function maskOrSummarize($: EngineInterface, { e, next, conversationTokens, state, settings }: Compaction) {
218 const { messages, stats } = maskOldToolResults(e.messages)
219 const estimate = await estimateFor($, { conversationTokens, original: e.messages, compacted: messages, stats })
220 const branch = chooseBranch({ conversationTokens, estimate, stats }, settings)
221 if (branch.kind === 'micro') {
222 state.decision = { kind: 'timer', outcome: { branch, conversationTokens, estimate, stats } }
223 return { messages, tokensBefore: conversationTokens, tokensAfter: estimate }
224 }
225 if (branch.kind === 'leave-alone') {
226 state.decision = { kind: 'timer', outcome: { branch, conversationTokens, estimate, stats } }
227 return { skip: 'a trim would save too little' }
228 }
229 const summary = await next(e).catch((err: unknown) => {
230 // Esc during the summary abandons the call, and core's summary rejects.
231 if (next.signal.aborted) state.decision = { kind: 'cancelled' }
232 throw err
233 })
234 state.decision = { kind: 'timer', outcome: { branch, conversationTokens, estimate, stats, summary } }
235 return summary
236}
237
238type RequestTrim = { e: SessionCompactInput; next: Next<'session.compact'>; conversationTokens: number | undefined; state: State }
239
240/**
241 * /micro-compact's compaction: masks old tool results and installs the result with no model call,
242 * whatever the conversation's size and however little it saves. A transcript with nothing to take
243 * out is left as it is. The size before, when known, is for the line alone.
244 */
245async function trimOnRequest($: EngineInterface, { e, next, conversationTokens, state }: RequestTrim) {
246 const { messages, stats } = maskOldToolResults(e.messages)
247 if (!removedAnything(stats)) {
248 state.decision = { kind: 'nothing-left' }
249 return { skip: 'nothing left to remove' }
250 }
251 if (conversationTokens === undefined) {
252 state.decision = { kind: 'trimmed', trim: { stats } }
253 return { messages }
254 }
255 const estimate = await estimateFor($, { conversationTokens, original: e.messages, compacted: messages, stats })
256 // Esc while the thinking was read abandons the call: the transcript stays as it is.
257 if (next.signal.aborted) {
258 state.decision = { kind: 'cancelled' }
259 return { skip: 'cancelled' }
260 }
261 state.decision = { kind: 'trimmed', trim: { stats, conversationTokens, estimate } }
262 return { messages, tokensBefore: conversationTokens, tokensAfter: estimate }
263}
264
265/** Runs /compact with the measured size held for the session.compact hook, and returns what the hook decided. */
266async function runOwnCompaction($: EngineInterface, state: State, conversationTokens: number | undefined, args: string) {
267 state.pending = { conversationTokens }
268 state.decision = undefined
269 // Through the /compact command rather than $.session.compact: the engine doesn't run a plugin's
270 // own hooks on an event that plugin's code raises outside a hook, so this module's session.compact
271 // hook would miss its own call. Raised by the command, the event reaches it.
272 // A /compact cancelled with Esc resolves as one that compacted: only the hook's decision tells them apart.
273 await $.command.run({ command: 'compact', args }).finally(() => {
274 state.pending = undefined
275 })
276 return takeDecision(state)
277}
278
279/**
280 * The line for a /compact that ended without the hook's decision standing: the person pressed Esc
281 * while the hook was deciding, or the /compact ended without raising it.
282 */
283const stoppedLine = (decision: Decision | undefined, when: When) =>
284 decision?.kind === 'cancelled'
285 ? 'not compacted: the compaction was cancelled'
286 : `not compacted at ${when.at}: the compaction stopped before the mod decided (cancelled?)`
287
288type Report = { outcome: Outcome; when: When; contextTokens: number; settings: Settings; isOvertaken: () => boolean }
289
290/**
291 * Writes the line for a compaction that stands to the transcript, and pins it as the status. A
292 * conversation left alone gets the transcript line alone, as one under the floor does.
293 */
294async function report($: EngineInterface, state: State, { outcome, when, contextTokens, settings, isOvertaken }: Report) {
295 if (outcome.branch.kind === 'leave-alone') {
296 $.ui.log(leftAloneLine({ when, outcome, minSavedShare: settings.minSavedShare }))
297 return
298 }
299 const { summary } = outcome
300 // A summary a hook beneath vetoed has left its own notice.
301 if (outcome.branch.kind === 'summary' && summary?.messages === undefined) return
302 const { status, log, debug } =
303 summary?.messages === undefined
304 ? microLine({ when, outcome })
305 : summaryLine({ when, outcome, result: summary, contextTokens, targetTokens: settings.microTargetTokens })
306 $.ui.log(log)
307 if (debug) $.ui.log(debug, { to: 'debug' })
308 // A turn started while the summary ran has already cleared the status; don't pin a stale one.
309 pinStatus($, state, status, isOvertaken)
310}
311
312type Run = { sizes: SizesBefore; when: IdleWhen; settings: Settings; isOvertaken: () => boolean }
313
314/** The timer's compaction once the size is known: compact, then report what the hook decided. */
315async function compactAndReport($: EngineInterface, state: State, { sizes, when, settings, isOvertaken }: Run) {
316 // A turn that started while the size was measured means the person is back: leave the conversation be.
317 if (isOvertaken()) return
318 const decision = await runOwnCompaction($, state, sizes.conversationTokens, instructions(when))
319 if (decision?.kind === 'summary-after-failure') {
320 reportSummaryAfterFailure($, state, { ...decision, when, contextTokens: sizes.contextTokens, settings, isOvertaken })
321 return
322 }
323 if (decision?.kind !== 'timer') {
324 $.ui.log(stoppedLine(decision, when))
325 return
326 }
327 await report($, state, { outcome: decision.outcome, when, contextTokens: sizes.contextTokens, settings, isOvertaken })
328}
329
330type FailureReport = { summary: SessionCompactResult; message: string; when: When; contextTokens: number; settings: Settings; isOvertaken: () => boolean }
331
332/**
333 * The timer's hook failed and core wrote its summary instead: the summary's line, as for a summary
334 * the hook never decided on, and the error in the debug log for tuning the mod.
335 */
336function reportSummaryAfterFailure($: EngineInterface, state: State, { summary, message, when, contextTokens, settings, isOvertaken }: FailureReport) {
337 $.ui.log(`session.compact hook failed, so core summarized: ${message}`, { to: 'debug' })
338 // A summary a hook beneath vetoed has left its own notice.
339 if (summary.messages === undefined) return
340 const { status, log, debug } = summaryLine({ when, outcome: undefined, result: summary, contextTokens, targetTokens: settings.microTargetTokens })
341 $.ui.log(log)
342 if (debug) $.ui.log(debug, { to: 'debug' })
343 pinStatus($, state, status, isOvertaken)
344}
345
346type RequestReport = { decision: Decision | undefined; when: When; isOvertaken: () => boolean }
347
348/** Writes the line for /micro-compact's run; a trim also pins the status. */
349function reportOnRequest($: EngineInterface, state: State, { decision, when, isOvertaken }: RequestReport) {
350 switch (decision?.kind) {
351 case 'trimmed': {
352 const { status, log } = microLine({ when, outcome: decision.trim })
353 $.ui.log(log)
354 pinStatus($, state, status, isOvertaken)
355 return
356 }
357 case 'nothing-left':
358 $.ui.log(nothingLeftLine(when))
359 return
360 case 'failed':
361 $.ui.log(`not compacted by /${COMMAND}: ${decision.message}`)
362 return
363 default:
364 $.ui.log(stoppedLine(decision, when))
365 }
366}
367
368const failedLine = (err: unknown) => `not compacted: ${err instanceof Error ? err.message : String(err)}`
369
370async function compactIfWorthIt($: EngineInterface, state: State, { idle, settings }: { idle: IdleWindow; settings: Settings }) {
371 const isOvertaken = overtakenSince(state)
372 try {
373 const sizes = await measureBefore($)
374 if (sizes === undefined) return
375 if (sizes.conversationTokens < settings.minTokens) {
376 $.ui.log(`not compacted: the conversation is ${approx(sizes.conversationTokens)} tokens, under the ${approx(settings.minTokens)} floor`)
377 return
378 }
379 const now = await $.clock.now()
380 const when: IdleWhen = { at: hhmm(now), idleMin: Math.round((now - idle.since) / MINUTE_MS) }
381 // A timer that fires late (the machine slept) finds the cache cold: compacting now would cost
382 // what the cold return costs, and lose detail on top.
383 if (now > idle.latest) {
384 $.ui.log(`not compacted: the timer fired ${when.idleMin} min after the last turn, too late for a warm cache`)
385 return
386 }
387 await compactAndReport($, state, { sizes, when, settings, isOvertaken })
388 } catch (err) {
389 // The next turn.complete re-arms the timer.
390 $.ui.log(failedLine(err))
391 }
392}
393
394/**
395 * /micro-compact: masks old tool results now and installs the result, whatever the conversation's
396 * size, with no summary. The size, when known, is measured for the line alone. It takes the place
397 * of the pending timer, which the next turn arms again.
398 */
399async function compactOnRequest($: EngineInterface, state: State) {
400 disarm(state)
401 const isOvertaken = overtakenSince(state)
402 try {
403 await forget($)
404 // Unknown right after a compaction, until the next response; a failed read is unknown too.
405 const sizes = await measureBefore($).catch(() => undefined)
406 const when: When = { at: hhmm(await $.clock.now()), command: COMMAND }
407 const decision = await runOwnCompaction($, state, sizes?.conversationTokens, ON_REQUEST)
408 reportOnRequest($, state, { decision, when, isOvertaken })
409 } catch (err) {
410 $.ui.log(failedLine(err))
411 }
412}
413
414/** Starts the timer, due in `dueInMs`. The idle time counts from `idleSince`. */
415function schedule($: EngineInterface, state: State, settings: Settings, { idleSince, dueInMs }: { idleSince: number; dueInMs: number }) {
416 const idle: IdleWindow = { since: idleSince, latest: idleSince + HOUR_MS }
417 state.timer = $.clock.after(dueInMs, () => {
418 state.timer = undefined
419 void compactIfWorthIt($, state, { idle, settings })
420 })
421}
422
423async function arm($: EngineInterface, state: State, settings: Settings) {
424 disarm(state)
425 const isOvertaken = overtakenSince(state)
426 const idleSince = await $.clock.now()
427 const sessionId = await $.session.id()
428 if (isOvertaken()) return
429 schedule($, state, settings, { idleSince, dueInMs: settings.idleMs })
430 // Kept for a reload of the module, which cancels the timer.
431 await $.state.set(LAST_TURN, { sessionId, at: idleSince })
432 // A turn.start that disarmed meanwhile may have cleared it before this write landed.
433 if (isOvertaken()) await forget($)
434}
435
436/** After a reload: re-arms for what is left of the delay since this session's last turn, if any. */
437async function rearm($: EngineInterface, state: State, settings: Settings) {
438 disarm(state)
439 const isOvertaken = overtakenSince(state)
440 const { value: lastTurn } = await $.state.get(LAST_TURN)
441 const sessionId = await $.session.id()
442 const now = await $.clock.now()
443 if (isOvertaken() || !lastTurn || lastTurn.sessionId !== sessionId) return
444 // Past the deadline the timer has fired already, or the session was resumed long after: either way
445 // there's nothing to do now.
446 const dueInMs = lastTurn.at + settings.idleMs - now
447 if (dueInMs > 0) schedule($, state, settings, { idleSince: lastTurn.at, dueInMs })
448}
449
450/**
451 * Arms a timer at the end of every main-loop turn and cancels it when the next turn starts, so it
452 * runs from the last model request: the last time the cache was refreshed. When it fires on time
453 * and the conversation is over the floor, it compacts while the cache is still warm: by masking
454 * old tool results and dropping thinking when that is estimated to leave the conversation under
455 * the target, with no model call, and by the model's summary when it would not. A trim that would
456 * save too little of the conversation is not made, and nothing else is either: the conversation is
457 * left as it is. Each outcome leaves a note in the transcript; a compaction also pins the status
458 * line. The time of the last turn is kept in `$.state`, so a reload of the module, which cancels
459 * the timer, re-arms it for the time left. `/micro-compact` masks at once, whatever the
460 * conversation's size and however little it saves, and never summarizes.
461 */
462export const register: Register = (on, options) => {
463 const settings = readSettings(options)
464 // A reloaded module can't tell whether the one before it pinned a status: assume it did.
465 const state: State = { generation: 0, hasStatus: true }
466
467 // Only this module's own compaction, which arrives as a /compact with its arguments: the person's
468 // own /compact, auto-compaction, a subagent's and other plugins' calls go on untouched.
469 on('session.compact', { trigger: 'manual' }, async ($, e, next) => {
470 if (isRequestRun(e)) return trimOnRequest($, { e, next, conversationTokens: state.pending?.conversationTokens, state })
471 if (!isTimerRun(e)) return next(e)
472 // Raised by someone other than this module, which measured nothing: measure now.
473 const conversationTokens =
474 state.pending?.conversationTokens ?? conversationTokensOf((await $.session.usage({ breakdown: 'full' })).context)
475 return conversationTokens === undefined ? next(e) : maskOrSummarize($, { e, next, conversationTokens, state, settings })
476 }).catch(async ($, e, next) => {
477 // /micro-compact never summarizes: a throw leaves the conversation as it is, and its line says why.
478 if (isRequestRun(e)) {
479 const message = next.error.message ?? next.error.kind
480 state.decision = { kind: 'failed', message }
481 return { skip: `idle-micro-compact could not trim: ${message}` }
482 }
483 // The timer's throw before deciding still leaves a compaction: core's summary, with the same instructions.
484 if (!isTimerRun(e)) return next(e)
485 const message = next.error.message ?? next.error.kind
486 const summary = await next(e)
487 state.decision = { kind: 'summary-after-failure', summary, message }
488 return summary
489 })
490
491 on('turn.start', async ($, e, next) => {
492 disarm(state)
493 clearStatus($, state)
494 await forget($)
495 return next(e)
496 })
497
498 on('turn.complete', async ($, e, next) => {
499 const result = await next(e)
500 if (e.agentId === undefined) {
501 // never let arming the timer break the turn
502 await arm($, state, settings).catch(() => {})
503 }
504 return result
505 })
506
507 on('session.end', async ($, e, next) => {
508 disarm(state)
509 clearStatus($, state)
510 await forget($)
511 return next(e)
512 })
513
514 // Also fires when the module is reloaded (/reload-plugins, an update, an options change), which
515 // cancels the timer: pick up where the last turn left off.
516 on('session.start', async ($, e, next) => {
517 const result = await next(e)
518 await $.command.register({
519 name: 'micro-compact',
520 description: 'Trim old tool results now, with no summary',
521 }).catch(() => {})
522 await rearm($, state, settings).catch(() => {})
523 return result
524 })
525
526 // Registered without `immediate`, so a /micro-compact typed during a turn waits for the turn to end.
527 // The compaction is not awaited: the /compact it runs waits for this command to finish, and the
528 // host refuses a $.command.run that a command.run hook would wait on.
529 on('command.run', { command: 'micro-compact' }, async ($) => {
530 void compactOnRequest($, state)
531 return {}
532 })
533}
534hooks/decide.ts 127 lines1import type { ApiMessage, SessionMessage } from 'claude-code'
2import type { MaskStats } from './mask.ts'
3
4/**
5 * How much of a message the model reads as text: its text, its tool calls' arguments and its tool
6 * results. Each tool use also carries its result's text, which the result message holds already,
7 * so it is left out to avoid counting a Read twice. Masking changes only tool results, so the
8 * difference before and after it is the tool-result text removed.
9 */
10function readableChars(messages: readonly SessionMessage[]) {
11 let n = 0
12 for (const m of messages) {
13 n += m.text.length
14 for (const u of m.toolUses) n += JSON.stringify(u.input).length
15 for (const r of m.toolResults ?? []) n += r.text.length
16 }
17 return n
18}
19
20// Token rates for Opus 5.5's tokenizer.
21// Tool-result text (file contents, command output, search results): about 2.3 characters a token.
22const TOKENS_PER_TOOL_RESULT_CHAR = 0.44
23// A thinking block sent back holds only its signature, not its text. Its cost follows the
24// signature's length less a fixed part per block, so a short signature costs nothing countable.
25const THINKING_TOKENS_PER_SIGNATURE_CHAR = 0.2745
26const THINKING_TOKENS_SUBTRACTED_PER_BLOCK = 274
27// Stopgap, not calibrated: a thinking block that carries its text costs at least a token per 4
28// characters of it.
29const THINKING_CHARS_PER_TOKEN = 4
30// An image the API reads at up to 1568 px on its long edge, scaled down to fit, at a token per
31// 750 pixels, and at most about 1600 tokens an image. Stopgap: a model that reads images at a
32// higher resolution costs more than this, so the estimate errs high for it.
33const IMAGE_MAX_LONG_EDGE_PX = 1568
34const PIXELS_PER_IMAGE_TOKEN = 750
35const MAX_TOKENS_PER_IMAGE = 1600
36// A PDF, or an image of unknown size: the message list shows it as no text.
37const TOKENS_PER_MASKED_MEDIA = 1000
38
39/** An image's or a PDF's size as the model reads it: in pixels for an image, unknown otherwise. */
40export type MediaSize = { width: number; height: number } | undefined
41
42/** The tokens a masked image or PDF costs. */
43export function mediaTokens(size: MediaSize) {
44 if (size === undefined) return TOKENS_PER_MASKED_MEDIA
45 const scale = Math.min(1, IMAGE_MAX_LONG_EDGE_PX / Math.max(size.width, size.height))
46 const pixels = Math.round(size.width * scale) * Math.round(size.height * scale)
47 return Math.min(MAX_TOKENS_PER_IMAGE, Math.round(pixels / PIXELS_PER_IMAGE_TOKEN))
48}
49
50/** One thinking block's tokens: from its signature's length, or its text's when that is more. */
51function thinkingTokensOf(block: ApiMessage['content'][number]) {
52 // Stopgap: a redacted block carries its encrypted thinking in `data` rather than a signature, and
53 // is costed at the signature rate until redacted blocks have a rate of their own.
54 const signature = typeof block.signature === 'string' ? block.signature : typeof block.data === 'string' ? block.data : ''
55 const bySignature = Math.max(0, THINKING_TOKENS_PER_SIGNATURE_CHAR * signature.length - THINKING_TOKENS_SUBTRACTED_PER_BLOCK)
56 const text = typeof block.thinking === 'string' ? block.thinking : ''
57 return Math.max(bySignature, text.length / THINKING_CHARS_PER_TOKEN)
58}
59
60/**
61 * The tokens of the thinking the conversation sends back with each request. The hook's message
62 * list shows thinking as empty rows, so this reads it from the Messages API form, where each
63 * thinking block keeps its signature. Dropping thinking-only messages removes all of it.
64 */
65export function thinkingTokensIn(messages: readonly ApiMessage[]) {
66 let n = 0
67 for (const m of messages) {
68 for (const block of m.content) if (block.type === 'thinking' || block.type === 'redacted_thinking') n += thinkingTokensOf(block)
69 }
70 return Math.round(n)
71}
72
73type EstimateInput = {
74 // the Messages row just before compacting
75 conversationTokens: number
76 original: readonly SessionMessage[]
77 compacted: readonly SessionMessage[]
78 // the thinking the compaction drops, as thinkingTokensIn counts it. Masking drops every thinking
79 // block, so this is also all the thinking the conversation holds.
80 droppedThinkingTokens: number
81 // the tokens of the images and PDFs the compaction replaced with a placeholder, as mediaTokens counts them
82 maskedMediaTokens: number
83}
84
85/**
86 * The conversation's size after micro-compaction: its measured size less the removed tool-result
87 * text, the dropped thinking and the masked images and PDFs, each at a fixed token rate. Two
88 * removals are not counted: attachments the engine drops along with a rebuilt message, and images
89 * in other tools' results, which the message list does not show. So the estimate errs high, toward
90 * the summary. No token-count request: the timer fires with minutes to spare, not hours.
91 */
92export function estimateAfter({ conversationTokens, original, compacted, droppedThinkingTokens, maskedMediaTokens }: EstimateInput) {
93 // A masked image or PDF swaps empty text for a longer placeholder: its text removes nothing.
94 const removedToolResultChars = Math.max(0, readableChars(original) - readableChars(compacted))
95 const removedTokens = TOKENS_PER_TOOL_RESULT_CHAR * removedToolResultChars + droppedThinkingTokens + maskedMediaTokens
96 return Math.max(0, Math.round(conversationTokens - removedTokens))
97}
98
99export type Branch = { kind: 'micro' } | { kind: 'summary' } | { kind: 'leave-alone' }
100
101/** The limits chooseBranch weighs the estimate against. */
102export type BranchLimits = {
103 // the conversation size at or under which a trim is enough
104 microTargetTokens: number
105 // the share of the conversation a trim must save to be worth rewriting the transcript
106 minSavedShare: number
107}
108
109type BranchInput = { conversationTokens: number; estimate: number; stats: MaskStats }
110
111/** Whether masking took anything out: a result masked, shortened or cut, or thinking dropped. */
112export const removedAnything = (stats: MaskStats) =>
113 stats.masked + stats.trimmedToHeadAndTail + stats.errorsCutToStart + stats.thinkingDropped > 0
114
115/**
116 * The summary when the trim is estimated to land over the target, whatever it saves. Under it, the
117 * conversation is left alone when the trim removes nothing or saves less than `minSavedShare` of
118 * it: a trim rewrites the transcript, so the next request writes all of it to the cache again for
119 * next to no saving. A trim otherwise.
120 */
121export function chooseBranch({ conversationTokens, estimate, stats }: BranchInput, limits: BranchLimits): Branch {
122 if (estimate > limits.microTargetTokens) return { kind: 'summary' }
123 const saved = conversationTokens - estimate
124 if (!removedAnything(stats) || saved < limits.minSavedShare * conversationTokens) return { kind: 'leave-alone' }
125 return { kind: 'micro' }
126}
127hooks/mask.ts 313 lines1// Derived from ruihe774/cc-micro-compaction, hooks/elide.ts at commit
2// 7409c6265aa977c01cfd6b070af3c734f56fb467 (https://github.com/ruihe774/cc-micro-compaction),
3// released into the public domain under the Unlicense; see LICENSE. The Read placeholders, the
4// thinking step and the handle rule are its; the window, the other tools and the path lists are
5// this mod's.
6
7import type { SessionMessage as Msg, ToolResultSummary, ToolUseSummary } from 'claude-code'
8import { type MediaSize, mediaTokens } from './decide.ts'
9
10export type MaskStats = {
11 /** Tool results replaced by a one-line placeholder */
12 masked: number
13 /** Of the masked results, the images and PDFs */
14 mediaMasked: number
15 /** What those images and PDFs cost, in tokens, as mediaTokens counts them */
16 mediaTokens: number
17 /** Command-style results cut to their first and last lines */
18 trimmedToHeadAndTail: number
19 /** Error results cut to their start */
20 errorsCutToStart: number
21 thinkingDropped: number
22}
23
24// The results of the newest tool uses are what the work in hand is about: they stay whole.
25const RECENT_TOOL_USES_KEPT = 5
26// A result this short costs less than what replacing it would lose.
27const MIN_MASKED_CHARS = 200
28// An error's opening says what failed; the rest is mostly a stack or a repeat.
29const ERROR_START_CHARS = 400
30
31const MASKED_TOOLS = new Set(['Read', 'Bash', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Edit', 'Write', 'MultiEdit'])
32const isMaskedTool = (tool: string) => MASKED_TOOLS.has(tool) || tool.startsWith('mcp__')
33// A file change's result only confirms it; the file itself holds what the change made.
34const FILE_CHANGE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
35
36// Command-style output opens with what ran and closes with how it ended; the middle is the bulk.
37const HEAD_AND_TAIL_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'WebFetch'])
38const HEAD_LINES = 15
39const TAIL_LINES = 15
40// Not worth a marker for a handful of hidden lines.
41const MIN_HIDDEN_LINES = 6
42// A head or tail of a few huge lines (minified code, a JSON blob) is cut to this many characters.
43const HEAD_OR_TAIL_MAX_CHARS = 1500
44
45const MARKER = ' elided by compaction: '
46const AGAIN = '. Read it again if needed.]'
47// Any of this module's placeholders and markers, so a second pass leaves them be
48const PLACEHOLDER = /^\[[^\]\n]* elided by compaction: /
49const CUT_MARKER = /^\[\.\.\. \d+ (?:lines|chars|chars of error output) elided by compaction/m
50
51// Read numbers its output as ` 12\tline`; the first and last numbers give the range read
52const LINE_NO = /^\s*(\d+)\t/
53
54/**
55 * Path-like tokens: an optional `/`, `./`, `../` or `~/` start, at least one directory segment,
56 * and a file name with an extension of 1-8 characters, bounded by whitespace, quotes, brackets,
57 * `:`, `,`, `;` or `=`. Names without an extension (Makefile) are not matched.
58 */
59const PATH_TOKEN = /(?:^|[\s"'`(\[=])((?:\/|\.{1,2}\/|~\/)?(?:[\w.@+-]+\/)+[\w.@+-]*[\w@+-]\.[A-Za-z0-9]{1,8})(?=$|[\s"'`)\]:,;])/gm
60// Enough to say which files the text was about without the list becoming the bulk again.
61const PATH_LIST_MAX = 50
62
63const pathOf = (input: Record<string, unknown>) => (typeof input.file_path === 'string' ? input.file_path : 'unknown file')
64
65/** The distinct path-like tokens of `text`, in order of first appearance. */
66export function pathsIn(text: string): string[] {
67 const seen = new Set<string>()
68 for (const m of text.matchAll(PATH_TOKEN)) seen.add(m[1]!)
69 return [...seen]
70}
71
72/**
73 * Names, inside a placeholder's closing bracket, the files the removed text named, so the model
74 * still knows which files the result was about and can read them again.
75 */
76function withPaths(placeholder: string, removed: string): string {
77 const paths = pathsIn(removed)
78 if (!paths.length) return placeholder
79 const more = paths.length > PATH_LIST_MAX ? ` (+${paths.length - PATH_LIST_MAX} more)` : ''
80 return `${placeholder.slice(0, -1)} Paths: ${paths.slice(0, PATH_LIST_MAX).join(', ')}${more}]`
81}
82
83function describeText(input: Record<string, unknown>, text: string): string {
84 let first: number | null = null
85 let last: number | null = null
86 for (const line of text.split('\n')) {
87 const m = LINE_NO.exec(line)
88 if (!m) continue
89 const n = Number(m[1])
90 first ??= n
91 last = n
92 }
93 const range = first === null ? '' : first === 1 ? `, ${last} lines` : `, lines ${first}-${last}`
94 return `[file contents${MARKER}${pathOf(input)}${range}${AGAIN}`
95}
96
97const isPositive = (n: unknown): n is number => typeof n === 'number' && n > 0
98
99/** An image's size as the model was sent it: its size after the Read resized it, or its original size. */
100function sentSize(d: any): MediaSize {
101 if (isPositive(d?.displayWidth) && isPositive(d?.displayHeight)) return { width: d.displayWidth, height: d.displayHeight }
102 if (isPositive(d?.originalWidth) && isPositive(d?.originalHeight)) return { width: d.originalWidth, height: d.originalHeight }
103 return undefined
104}
105
106/**
107 * The placeholder for a Read whose bulk is an image or document block rather than text
108 * (`result.type` image, pdf, or parts: PDF pages rendered as images), and the tokens the block
109 * costs; null for any other. Their `text` is empty or a one-line note, so length says nothing
110 * about their size.
111 */
112function describeMedia(input: Record<string, unknown>, result: unknown): { text: string; tokens: number } | null {
113 if (!result || typeof result !== 'object') return null
114 const r = result as { type?: unknown; file?: any; firstPage?: unknown }
115 const path = pathOf(input)
116 switch (r.type) {
117 case 'image': {
118 const d = r.file?.dimensions
119 const size = d?.originalWidth && d?.originalHeight ? `, ${d.originalWidth}x${d.originalHeight}` : ''
120 return { text: `[image${MARKER}${path}${size}${AGAIN}`, tokens: mediaTokens(sentSize(d)) }
121 }
122 case 'pdf':
123 return { text: `[PDF${MARKER}${path}${AGAIN}`, tokens: mediaTokens(undefined) }
124 case 'parts': {
125 const first = typeof r.firstPage === 'number' ? r.firstPage : null
126 const count = typeof r.file?.count === 'number' ? r.file.count : null
127 const pages =
128 typeof input.pages === 'string' ? input.pages : first !== null && count !== null ? `${first}-${first + count - 1}` : null
129 return { text: `[PDF pages${MARKER}${path}${pages ? `, pages ${pages}` : ''}${AGAIN}`, tokens: mediaTokens(undefined) }
130 }
131 default:
132 return null
133 }
134}
135
136const oneLine = (s: string, max: number) => {
137 const t = s.replace(/\s+/g, ' ').trim()
138 return t.length > max ? `${t.slice(0, max)}…` : t
139}
140
141/** Leading `cd x &&` steps say where a command ran, not what it was. */
142function withoutCd(command: string): string {
143 const CD_STEP = /^cd\s+("[^"]*"|'[^']*'|\S+)\s*&&\s*/
144 let c = command.trim()
145 for (let m = CD_STEP.exec(c); m; m = CD_STEP.exec(c)) c = c.slice(m[0].length)
146 return c.replace(/\s+/g, ' ').trim()
147}
148
149// Long enough to tell one call from another, short enough to stay one line.
150const COMMAND_KEY_MAX_CHARS = 120
151const SEARCH_KEY_MAX_CHARS = 160
152const URL_KEY_MAX_CHARS = 200
153const MCP_KEY_MAX_CHARS = 200
154
155/** What to read or rerun to get a removed result back. */
156function recoveryKey(use: ToolUseSummary): string {
157 const str = (k: string) => (typeof use.input[k] === 'string' ? (use.input[k] as string) : '')
158 if (use.tool === 'Bash') return oneLine(withoutCd(str('command')), COMMAND_KEY_MAX_CHARS)
159 if (use.tool === 'Grep' || use.tool === 'Glob') return oneLine([str('pattern'), str('path')].filter(Boolean).join(' in '), SEARCH_KEY_MAX_CHARS)
160 if (use.tool === 'WebSearch') return oneLine(str('query'), SEARCH_KEY_MAX_CHARS)
161 if (use.tool === 'WebFetch') return oneLine(str('url'), URL_KEY_MAX_CHARS)
162 if (use.tool.startsWith('mcp__')) return oneLine(`${use.tool} ${JSON.stringify(use.input)}`, MCP_KEY_MAX_CHARS)
163 return str('file_path') || use.tool
164}
165
166const KIND_OF_OUTPUT: Record<string, string> = {
167 Bash: 'command output',
168 Grep: 'search results',
169 Glob: 'search results',
170 WebSearch: 'search results',
171 WebFetch: 'web page',
172}
173
174const sizeOf = (text: string) => {
175 const lines = text.split('\n').length
176 return lines > 1 ? `${lines} lines` : `${text.length} chars`
177}
178
179/**
180 * `[<kind> elided by compaction: <what to rerun>, <size>. Rerun it if needed.]`. A file change
181 * names its file instead: rerunning it would apply the change twice.
182 */
183function describeOutput(use: ToolUseSummary, text: string): string {
184 if (FILE_CHANGE_TOOLS.has(use.tool)) {
185 return `[file change result${MARKER}${pathOf(use.input)}, ${sizeOf(text)}. The change is made; read the file again if needed.]`
186 }
187 const kind = KIND_OF_OUTPUT[use.tool] ?? 'tool result'
188 return `[${kind}${MARKER}${recoveryKey(use)}, ${sizeOf(text)}. Rerun it if needed.]`
189}
190
191/** Marks text cut from a head or tail line, listing the paths it named. */
192const cutMarker = (removed: string) => withPaths(`[... ${removed.length} chars elided by compaction]`, removed)
193
194/** The head's first HEAD_OR_TAIL_MAX_CHARS characters, marked when the cut falls inside it. */
195function cappedHead(head: string): string {
196 if (head.length <= HEAD_OR_TAIL_MAX_CHARS) return head
197 return `${head.slice(0, HEAD_OR_TAIL_MAX_CHARS)}\n${cutMarker(head.slice(HEAD_OR_TAIL_MAX_CHARS))}`
198}
199
200/** The tail's last HEAD_OR_TAIL_MAX_CHARS characters, where output ends with how it went, marked when cut. */
201function cappedTail(tail: string): string {
202 if (tail.length <= HEAD_OR_TAIL_MAX_CHARS) return tail
203 return `${cutMarker(tail.slice(0, -HEAD_OR_TAIL_MAX_CHARS))}\n${tail.slice(-HEAD_OR_TAIL_MAX_CHARS)}`
204}
205
206/** The first and last lines of command-style output around a marker, or null when there are too few to cut. */
207function headAndTail(use: ToolUseSummary, text: string): string | null {
208 const lines = text.split('\n')
209 const hidden = lines.length - HEAD_LINES - TAIL_LINES
210 if (hidden < MIN_HIDDEN_LINES) return null
211 const head = cappedHead(lines.slice(0, HEAD_LINES).join('\n'))
212 const tail = cappedTail(lines.slice(lines.length - TAIL_LINES).join('\n'))
213 const middle = lines.slice(HEAD_LINES, lines.length - TAIL_LINES).join('\n')
214 const marker = `[... ${hidden} lines elided by compaction; rerun ${recoveryKey(use)} if needed]`
215 return `${head}\n${withPaths(marker, middle)}\n${tail}`
216}
217
218/** An error's first ERROR_START_CHARS characters and a marker, or null when it is no longer than that. */
219function errorStart(text: string): string | null {
220 if (text.length <= ERROR_START_CHARS) return null
221 const removed = text.slice(ERROR_START_CHARS)
222 const marker = `[... ${removed.length} chars of error output elided by compaction]`
223 return `${text.slice(0, ERROR_START_CHARS)}\n${withPaths(marker, removed)}`
224}
225
226// `mediaTokens` is set on the placeholder of an image or PDF: what the block it replaces costs.
227type Replacement = { text: string; kind: 'masked' | 'trimmedToHeadAndTail' | 'errorsCutToStart'; mediaTokens?: number }
228
229/** What an old result of a masked tool becomes before the length check, or null to keep it whole. */
230function shortenedText(use: ToolUseSummary, r: ToolResultSummary): Replacement | null {
231 if (r.isError) {
232 const text = errorStart(r.text)
233 return text === null ? null : { text, kind: 'errorsCutToStart' }
234 }
235 if (r.text.length <= MIN_MASKED_CHARS) return null
236 const trimmed = HEAD_AND_TAIL_TOOLS.has(use.tool) ? headAndTail(use, r.text) : null
237 if (trimmed !== null) return { text: trimmed, kind: 'trimmedToHeadAndTail' }
238 const placeholder = use.tool === 'Read' ? describeText(use.input, r.text) : describeOutput(use, r.text)
239 return { text: withPaths(placeholder, r.text), kind: 'masked' }
240}
241
242/** What an old result of a masked tool becomes, or null to keep it whole. */
243function replacementFor(use: ToolUseSummary, r: ToolResultSummary): Replacement | null {
244 // An image or PDF's bulk is a block its text doesn't show, so its placeholder always saves.
245 const media = use.tool === 'Read' && !r.isError ? describeMedia(use.input, r.result) : null
246 if (media !== null) return { text: media.text, kind: 'masked', mediaTokens: media.tokens }
247 const replacement = shortenedText(use, r)
248 // Already short (an "unchanged since last read" stub, a tiny file, a short error)
249 return replacement !== null && replacement.text.length < r.text.length ? replacement : null
250}
251
252const isAlreadyCompacted = (text: string) => PLACEHOLDER.test(text) || CUT_MARKER.test(text)
253
254/**
255 * The ids of the newest RECENT_TOOL_USES_KEPT uses of masked tools, across the whole conversation.
256 * Other calls (a todo list, a question, a subagent) don't count: they would push the result in
257 * hand out of the window.
258 */
259function recentToolUses(messages: readonly Msg[]): Set<string> {
260 const ids = messages.flatMap((m) => m.toolUses.filter((u) => isMaskedTool(u.tool)).map((u) => u.tool_use_id))
261 return new Set(ids.slice(-RECENT_TOOL_USES_KEPT))
262}
263
264// Thinking blocks come as assistant messages of their own (one transcript entry per block),
265// which read as no text and no tool uses
266function isThinkingOnly(m: Msg): boolean {
267 return m.role === 'assistant' && !m.text && m.toolUses.length === 0
268}
269
270/**
271 * Masks the results of every tool use but the newest few and drops thinking. Old Read results
272 * become a placeholder naming the file and line range; old command-style output keeps its first
273 * and last lines; old errors keep their start; every placeholder and marker lists the files the
274 * removed text named. Every other message keeps its handle, so the engine keeps it whole; a user
275 * message holding a changed result is rebuilt without it.
276 */
277export function maskOldToolResults(messages: readonly Msg[]): { messages: Msg[]; stats: MaskStats } {
278 const uses = new Map<string, ToolUseSummary>()
279 for (const m of messages) for (const u of m.toolUses) uses.set(u.tool_use_id, u)
280 const recent = recentToolUses(messages)
281
282 const stats: MaskStats = { masked: 0, mediaMasked: 0, mediaTokens: 0, trimmedToHeadAndTail: 0, errorsCutToStart: 0, thinkingDropped: 0 }
283 const out: Msg[] = []
284 for (const m of messages) {
285 if (isThinkingOnly(m)) {
286 stats.thinkingDropped++
287 continue
288 }
289 let changed = false
290 const toolResults = m.toolResults?.map((r) => {
291 const use = uses.get(r.tool_use_id)
292 if (!use || !isMaskedTool(use.tool) || recent.has(r.tool_use_id) || isAlreadyCompacted(r.text)) return r
293 const replacement = replacementFor(use, r)
294 if (replacement === null) return r
295 changed = true
296 stats[replacement.kind]++
297 if (replacement.mediaTokens !== undefined) {
298 stats.mediaMasked++
299 stats.mediaTokens += replacement.mediaTokens
300 }
301 // Rebuilt without the record, so no image or document block comes back
302 return { tool_use_id: r.tool_use_id, isError: r.isError, text: replacement.text }
303 })
304 if (!changed) {
305 out.push(m)
306 continue
307 }
308 const { handle: _, ...rest } = m
309 out.push({ ...rest, toolResults })
310 }
311 return { messages: out, stats }
312}
313hooks/lines.ts 112 lines1import type { ModelUsage, SessionCompactResult, SessionCompacted } from 'claude-code'
2import type { Branch } from './decide.ts'
3import type { MaskStats } from './mask.ts'
4
5/** What the session.compact hook decided, for the line written once the compaction stands. */
6export type Outcome = {
7 branch: Branch
8 // the conversation (the Messages row) just before the compaction
9 conversationTokens: number
10 estimate: number
11 stats: MaskStats
12 // core's answer on the summary branch
13 summary?: SessionCompactResult
14}
15
16/** When the compaction ran, and what set it off: the idle timer after `idleMin` minutes, or a slash command. */
17export type When = { at: string; idleMin: number } | { at: string; command: string }
18
19/** What set the compaction off, as the line says it: `after 50 min idle`, `by /micro-compact`. */
20const cause = (when: When) => ('command' in when ? `by /${when.command}` : `after ${when.idleMin} min idle`)
21
22/** One compaction in up to three renderings: a short pinned status, the transcript log, and tuning detail for the debug log. */
23export type Lines = { status: string; log: string; debug?: string }
24
25export const inThousands = (n: number) => `${Math.round(n / 1000)}k`
26// Small figures as they are: a 100-token floor isn't "0k".
27export const approx = (n: number) => (n < 1000 ? `${n}` : inThousands(n))
28const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
29
30/** How much of the summary request's input the prompt cache served: the saving this mod exists for. */
31function cacheShare(usage: ModelUsage | undefined) {
32 if (!usage) return ''
33 const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
34 return input ? `summary request ${Math.floor((usage.cache_read_input_tokens * 100) / input)}% cached` : ''
35}
36
37/** Why trimming wasn't enough, for the debug line; empty when the hook never decided. */
38function whyNotMicro(outcome: Outcome | undefined, targetTokens: number) {
39 if (outcome?.branch.kind !== 'summary') return ''
40 return `trim estimate ${approx(outcome.estimate)}, target ${approx(targetTokens)}`
41}
42
43/**
44 * What a trim took out, with the conversation's size before it and the estimate after it. The
45 * sizes are absent when the size before was unknown, as right after a compaction.
46 */
47export type Trim = { stats: MaskStats; conversationTokens?: number; estimate?: number }
48
49type MicroLineInput = { when: When; outcome: Trim }
50
51/** The micro branch: the conversation before and the estimate after, when known, and what was taken out. */
52export function microLine({ when, outcome }: MicroLineInput): Lines {
53 const { masked, trimmedToHeadAndTail, errorsCutToStart, thinkingDropped } = outcome.stats
54 const removed = [
55 plural(masked, 'old tool result removed', 'old tool results removed'),
56 `${trimmedToHeadAndTail} shortened`,
57 // Rare enough to leave out when there are none.
58 ...(errorsCutToStart ? [plural(errorsCutToStart, 'error cut to its start', 'errors cut to their start')] : []),
59 plural(thinkingDropped, 'thinking block dropped', 'thinking blocks dropped'),
60 ].join(', ')
61 const { conversationTokens, estimate } = outcome
62 if (conversationTokens === undefined || estimate === undefined) {
63 return { status: `trimmed at ${when.at}`, log: `trimmed at ${when.at} ${cause(when)} (${removed})` }
64 }
65 const sizes = `${approx(conversationTokens)} → ~${approx(estimate)} tokens`
66 return { status: `trimmed at ${when.at}: ${sizes}`, log: `trimmed at ${when.at} ${cause(when)}: ${sizes} (${removed})` }
67}
68
69/** A trim that found nothing to take out, so the transcript stays as it is. */
70export const nothingLeftLine = (when: When) =>
71 `trimmed at ${when.at} ${cause(when)}: nothing left to remove (old tool results are already placeholders or among the 5 newest)`
72
73// A share as a percentage, to a tenth at most: 0.1 is "10%", 0.025 is "2.5%".
74const percent = (share: number) => `${Number((share * 100).toFixed(1))}%`
75
76type LeftAloneLineInput = { when: When; outcome: Outcome; minSavedShare: number }
77
78/** The leave-alone branch, for the transcript alone: what a trim would save, against the minimum. */
79export function leftAloneLine({ when, outcome, minSavedShare }: LeftAloneLineInput) {
80 const saved = Math.max(0, outcome.conversationTokens - outcome.estimate)
81 return (
82 `not trimmed at ${when.at} ${cause(when)}: a trim would save ~${approx(saved)} of ` +
83 `${approx(outcome.conversationTokens)} tokens, under the ${percent(minSavedShare)} minimum`
84 )
85}
86
87type SummaryLineInput = {
88 when: When
89 outcome: Outcome | undefined
90 result: SessionCompacted
91 // the whole context just before the compaction, for when core's answer doesn't say
92 contextTokens: number
93 targetTokens: number
94}
95
96/**
97 * The summary branch: the sizes in the status and the log. The debug line holds why trimming wasn't
98 * enough and the cache share, and is absent when neither is known. With no outcome the hook never
99 * decided (it was skipped), and there is no reason.
100 */
101export function summaryLine({ when, outcome, result, contextTokens, targetTokens }: SummaryLineInput): Lines {
102 // tokensBefore is the whole context; tokensAfter is only the summary, without the system prompt and tools.
103 const before = approx(result.tokensBefore ?? contextTokens)
104 const sizes = result.tokensAfter === undefined ? `${before} tokens` : `${before} → ${approx(result.tokensAfter)} summary`
105 const details = [whyNotMicro(outcome, targetTokens), cacheShare(result.usage)].filter(Boolean).join('; ')
106 return {
107 status: `summarized at ${when.at}: ${sizes}`,
108 log: `summarized at ${when.at} ${cause(when)}: ${sizes}`,
109 ...(details ? { debug: `summary chosen: ${details}` } : {}),
110 }
111}
112types/index.d.ts 12 lines1// idle-micro-compact's contract: the one value it keeps in `$.state`.
2
3/** When a session's last main-loop turn ended, kept so a reload of the module can re-arm its timer. */
4export type IdleMicroCompactLastTurn = { sessionId: string; at: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 // null while a turn runs and after the session ends
9 'idle-micro-compact': { lastTurn: IdleMicroCompactLastTurn | null }
10 }
11}
12