SLOPSHOPPER

idle-micro-compact

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…

commandstatustimer
v0.4.1MITupdated 2026-10-13kylesnowschwartz/idle-micro-compact
A shopper browsing a rack in a slop shop
README

idle-micro-compact

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.

What it saves

One idle 150k-token conversation on Claude Opus 5.5, from the idle pause to your first message back:

What happensTokens re-cached on returnTotal costSaved with the plugin
Without the plugin: nothing150k$1.20—
Without the plugin: /compact after the cache expired10k$1.48−$0.28
With the plugin: summary before the cache expires10k$0.31$0.89 (74%)
With the plugin: old tool results masked50k$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:

  • Without the plugin, /compact on a cold cache writes all 150k tokens ($1.20) and 10k of summary output ($0.20).
  • With the plugin, the summary request reads the 150k tokens from cache ($0.03) and writes 10k of output ($0.20). Masking makes no model call ($0).
  • The first request back writes the remaining conversation to a new cache at $8 per million tokens.

Assumptions:

  • Claude Opus 5.5 first-party API prices per million tokens: $4 base input, $8 for a 1-hour cache write, $0.20 for a cache read, and $20 output.
  • The plugin fires after 50 minutes idle. You return after the 1-hour cache expires.
  • A summary is 10k tokens. Masking leaves 50k tokens.
  • A request on a cold cache is billed as a 1-hour cache write.
  • System prompts, tools, and thinking tokens are left out.

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.

Install

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.

Configure

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
      }
    }
  }
}
OptionDefaultMeaning
idleMinutes50Minutes idle before compacting. 0 also uses the 50-minute default.
minTokens40000Leave smaller conversations alone. This counts the Messages row, not the system prompt or tools.
microTargetTokens150000Use masking with no model call when the estimated result is at or below this size.
minSavedShare0.1Leave 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.

How it works

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:

  1. It reads the conversation size from the Messages row of /context. If that row is unavailable, it uses the whole context. If the size is unknown, it leaves the session alone.
  2. It skips conversations below minTokens.
  3. It skips compaction if the timer fired after the 1-hour cache deadline, such as after the machine slept.
  4. It runs /compact. Its compaction hook masks old tool results and drops thinking, then estimates the remaining conversation size.
  5. If the estimate is above microTargetTokens, Claude Code writes a summary.
  6. If masking would save less than 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.
  7. Otherwise, it installs the masked transcript with no model call.

The automatic request appears in the transcript as a manual /compact line.

Run /micro-compact to trim now instead of waiting for the timer:

  • It always masks old tool results and drops thinking, with no model call. It never writes a summary.
  • It ignores minTokens, microTargetTokens, minSavedShare, and the cache deadline. It trims however large the conversation is and however little the trim saves.
  • It also runs when the size is unknown, as right after a compaction. Its line then leaves out the sizes.
  • It appears in the transcript as /compact idle-micro-compact: trim on request.
  • When there is nothing left to mask, it leaves the conversation unchanged and writes trimmed at 14:52 by /micro-compact: nothing left to remove (old tool results are already placeholders or among the 5 newest).
  • Typed during a turn, it runs when the turn ends.
  • It cancels the idle timer. The next turn starts a new one.

What micro-compaction keeps and drops

ResultWhat stays
Read textA shorter one-line placeholder with the file and line range.
Images and PDFsA placeholder with the file name.
Bash, Grep, Glob, WebFetch outputThe first and last 15 lines when shorter. Otherwise, a placeholder. Each end has a 1,500-character cap.
Other tool resultsA 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.
ErrorsThe first 400 characters when shorter. Other errors stay whole.
Thinking blocksNothing. 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.

How much micro-compaction removes

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.

Log lines

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
  • On a trimmed line, the second size is the estimate used to choose trimming over a summary.
  • On a summarized line, the first size is the whole context. The second is the summary alone.
  • shortened counts tool results cut to their head and tail. Errors cut to their start are listed only when there are some.
  • A summarized compaction also writes one debug-log line, 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.

Caveats

  • The estimate tends to be high. It subtracts removed tool output, dropped thinking, and masked images and PDFs at fixed token rates for Opus 5.5. An image counts by its size, up to 1,600 tokens. A PDF counts 1,000. It does not count attachments Claude Code drops with a rewritten message, or images inside MCP tool results. A conversation near microTargetTokens may get a summary when trimming would have been enough.
  • Micro-compaction rewrites the transcript from the first changed message. The next request re-caches from that point. This is cheaper than re-caching the whole conversation cold, but it is not free.
  • Masked output is removed from context. The replacement names what Claude can read or rerun, but the removed content itself is unavailable.
  • Rewritten messages lose their attached hidden context. Most rows are sent again later or become outdated: skill lists, reminders, and nested 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.
  • The timing assumes a 1-hour cache. With a 5-minute cache, the default timer fires after the cache is cold.

Upstream projects

This mod combines work from two projects:

  • davidar/claude-idle-compact (MIT) provides the idle timer, size floor, late-timer check, summary instructions, and log wording. hooks/register.ts and tests/register.test.ts are adapted from it.
  • ruihe774/cc-micro-compaction (Unlicense) provides the original 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.

Develop

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.

License

MIT. See LICENSE for this mod's notice and both upstream notices.

Source 5 files
hooks/register.ts 534 lines
1import 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}
534
hooks/decide.ts 127 lines
1import 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}
127
hooks/mask.ts 313 lines
1// 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}
313
hooks/lines.ts 112 lines
1import 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}
112
types/index.d.ts 12 lines
1// 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