SLOPSHOPPER

warm-compact

Compact a large idle session while its one-hour prompt cache is still warm

newcommandprompttimer
v0.2.1MITupdated 2026-10-24ivbala1/warm-compact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · warm-compact
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /warm-compact ⎿ warm-compact: warm-compact 0.2.1: on in this session. Compacts 52 min after the last model request when the context is at le ⎿ warm-compact: Nothing compacted or skipped in this session yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

warm-compact

A mod for Claude Code that compacts a large idle session a few minutes before its prompt cache expires, so that coming back to it is cheap.

Claude Code keeps a one-hour prompt cache. If you return to a large session after more than an hour, the whole context is written to the cache again at twice the input price, while reading it from a warm cache costs 0.1. The mod compacts a large idle session while the cache is still warm: the summary reads the conversation from the cache, and your next return starts from a small context.

The mod assumes the one-hour cache of a Claude subscription. Where the cache lives five minutes (some API key, Bedrock or Vertex setups), the summary would read a cold context and cost more than doing nothing: do not install it there.

It works in sessions that are drawn somewhere: the Code tab of the Claude desktop app, an interactive terminal session, or a connected client. Headless runs (claude -p, SDK scripts, bots without a surface) are never touched.

Install

In a terminal session:

/plugin install warm-compact --marketplace ivbala1/warm-compact

Answer y to add the marketplace and pick the user scope. New sessions get the mod on their own; in a session that is already open, send /reload-plugins, then /warm-compact status to see it loaded.

How it works

  • The count starts at the last model request. The mod measures time from the start of the last model request of the main loop.
  • Compaction. After idleMinutes of silence, if the context is at least minTokens, the session is compacted.
  • Cold cache is skipped. If the timer fires later than cutoffMinutes after the last request (for example the computer was asleep), the cache is already cold and the mod does nothing.
  • A used-up limit is skipped. While a usage-limit window stands at 100% and has not reset, the mod does not compact, by hand or not: the summary request would be refused until the reset, or billed as extra usage if that is on. A session that waits for the reset is left as it is.
  • Subagents. While subagents run, the mod skips.
  • Cancelling. Any new turn, /clear, or a compaction done by someone else cancels the timer.
  • Reload. A plugin reload arms the timer again for the time that is left.
  • Two ways to compact. In the desktop app (an SDK session) the direct call is refused, so the mod runs /compact as a queued command. In a terminal it calls the compaction directly.
  • Your message wins. If a turn runs or someone else compacts before the queued /compact starts, the mod drops its compaction: the cache is warm again or the context is small already.

The summary

The mod gives the summary its own instructions: write a note for continuing the work, with the goal, what is done, what is left and the next step, the files touched, decisions and permissions with their reasons, rejected options and why (briefly), the exact paths, file names, commands, commits, identifiers and numbers needed to continue and verify the work (not rounded, leaving out intermediate and repeated ones), open background work, what was not verified, and dates. Your noteHint is added to the end.

Each summary the mod produces is also saved as a Markdown file under ~/.claude/plugins/data/warm-compact/summaries/ (or under $CLAUDE_CONFIG_DIR when it is set), named by time and session id. The log line names the file. Use it to check what the compaction kept, to add what it missed, or to hand the note to another session. The files stay on your machine with no time limit: the mod never deletes them. A summary takes 10-30 KB, so even daily compactions add only some megabytes a year; delete old files by hand if you like. The first line of each file is an HTML comment with the session, time and sizes. If the file cannot be written within five seconds, the compaction stands and only the file is missing.

Commands

CommandEffect
/warm-compact statusVersion, session and state
/warm-compact nowCompact at once, ignoring idle time and size; refused while a turn runs, skipped while the usage limit is used up
/warm-compact offTurn the mod off in this session
/warm-compact onTurn it back on in this session

off and on apply to the current session only.

Settings

Set them with /plugin configure warm-compact@warm-compact, or in settings.json; unset options keep the defaults below:

{
  "pluginConfigs": {
    "warm-compact@warm-compact": {
      "options": { "idleMinutes": 52, "cutoffMinutes": 57, "minTokens": 200000, "language": "en", "noteHint": "" }
    }
  }
}
OptionDefaultMeaning
idleMinutes52Minutes after the last model request before compaction; keep it a few minutes under the one-hour cache
cutoffMinutes57A timer that fires later than this after the last request skips, because the cache is cold already
minTokens200000Context size below which a cold re-read is cheap and the session is left as it is
languageenen or ru: the mod's messages and its instructions to the summary
noteHintemptyExtra text added to the summary instructions, for example what else to keep

Journal

The mod writes one line to the transcript for every decision (compacted or skipped, and why). It also keeps the last 500 records in the plugin store under the key log:

{ "sessionId": "", "at": "", "outcome": "", "reason": "", "manual": false,
  "idleMinutes": 0, "tokensBefore": 0, "tokensAfter": 0, "cacheShare": 0,
  "summaryInput": 0, "summaryOutput": 0 }

Built-in idle compaction

Claude Code 2.1.290 and later has its own idleCompaction setting (about 54 minutes, 200K tokens and more, documented for the terminal). In a terminal both may run. If you want only this mod, set "idleCompaction": false.

Limits

  • The computer has to be awake and the app or terminal session open; the mod does not start a closed app.
  • The mod can only shorten the context, it does not make the summary perfect: details not written into the note may be lost, as with any compaction.

Development

Tested on Claude Code 2.1.308.

claude plugin validate --strict .
claude plugin test .

MIT License.

Source 3 files
hooks/register.ts 490 lines
1import type { EngineInterface, Register, SessionCompacted, SessionCompactResult, SessionRateLimit, Timer } from 'claude-code';
2import { cachePercent, clockTime, instructions, settings, t, tokens, type Config, type MessageKey } from './text.ts';
3
4// A one-hour prompt cache lapses an hour after the last request that read or
5// wrote it. The main loop's last model request starts the count; when the
6// session has idled for `idleMinutes` since, with a large enough context, the
7// mod compacts it while the cache is still warm, so the summary reads the
8// conversation at the cache price and the next return starts small.
9
10const VERSION = '0.2.1';
11const ARMED = { plugin: 'warm-compact', key: 'armed' } as const;
12const LOG_KEY = 'log';
13const LOG_LIMIT = 500;
14const BUSY_AGENTS = ['pending', 'running', 'waiting'];
15// A compaction that has not reported back by then is given up on, so a call
16// that never settles cannot switch the mod off for the rest of the session.
17const STALE_MS = 10 * 60_000;
18
19type Outcome = 'compacted' | 'skipped' | 'failed';
20
21export type LogEntry = {
22  sessionId: string;
23  at: string;
24  outcome: Outcome;
25  reason: string;
26  manual: boolean;
27  idleMinutes: number;
28  tokensBefore?: number;
29  tokensAfter?: number;
30  cacheShare?: number;
31  summaryInput?: number;
32  summaryOutput?: number;
33  summaryFile?: string;
34};
35
36type Compacting = {
37  token: number;
38  sessionId: string;
39  startedAt: number;
40  idleMinutes: number;
41  tokensBefore?: number;
42  manual: boolean;
43  // The last request the cache was refreshed by: the cutoff counts from it.
44  since: number;
45  // The instructions this mod sent: they tell its own /compact from someone else's.
46  note: string;
47  // Set when the main loop sent a request or someone else compacted after the
48  // mod queued /compact: the queued command then has nothing left to do.
49  overtaken?: string;
50};
51
52let cfg: Config = settings();
53const say = (key: MessageKey, vars?: Record<string, string | number>) => t(cfg, key, vars);
54
55// Bumped on every disarm, so a timer or an arm still awaiting its reads can
56// tell it was overtaken.
57let generation = 0;
58let timer: Timer | undefined;
59let dueAt: number | undefined;
60// The request the armed timer counts from, as kept in $.state.
61let armedSince: number | undefined;
62let lastRequestAt: number | undefined;
63let working = false;
64let compacting: Compacting | undefined;
65// Whether `$.session.compact()` works here: unknown in a terminal, false in an
66// SDK session (the desktop app), which compacts only inside a /compact turn.
67let direct: boolean | undefined;
68
69function disarm() {
70  generation += 1;
71  timer?.cancel();
72  timer = undefined;
73  dueAt = undefined;
74  armedSince = undefined;
75}
76
77function forget($: EngineInterface) {
78  return $.state.set(ARMED, null).catch(() => {});
79}
80
81async function isOff($: EngineInterface, sessionId: string) {
82  return (await $.store.get(`off:${sessionId}`)) === true;
83}
84
85async function record($: EngineInterface, entry: LogEntry, line: string) {
86  try {
87    const saved = await $.store.get(LOG_KEY);
88    const log = Array.isArray(saved) ? (saved as LogEntry[]) : [];
89    await $.store.set(LOG_KEY, [...log, entry].slice(-LOG_LIMIT));
90  } finally {
91    await $.ui.log(line);
92  }
93}
94
95function entry(c: Pick<Compacting, 'sessionId' | 'idleMinutes' | 'manual' | 'tokensBefore'>, at: number, outcome: Outcome, reason: string): LogEntry {
96  return { sessionId: c.sessionId, at: new Date(at).toISOString(), outcome, reason, manual: c.manual, idleMinutes: c.idleMinutes, tokensBefore: c.tokensBefore };
97}
98
99// Saving the summary never holds up the compaction's result for longer than this.
100const SAVE_MS = 5_000;
101
102/** Where the summaries are kept: beside the engine's own data, one file per compaction. */
103async function summaryDir($: EngineInterface): Promise<string | undefined> {
104  const base = (await $.env.get('CLAUDE_CONFIG_DIR')) || (await home($));
105  return base && `${base.replace(/[\\/]+$/, '')}/plugins/data/warm-compact/summaries`;
106}
107
108async function home($: EngineInterface): Promise<string | undefined> {
109  const dir = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'));
110  return dir ? `${dir.replace(/[\\/]+$/, '')}/.claude` : undefined;
111}
112
113/** Keeps the summary as a file, to check or hand on later; a failure only loses the file. */
114async function saveSummary($: EngineInterface, c: Compacting, at: number, result: SessionCompacted): Promise<string | undefined> {
115  // From core the summary comes first, then the messages it kept.
116  const first = result.messages[0];
117  const summary = first?.role === 'user' ? first.text.trim() : '';
118  if (!summary) return undefined;
119  const write = async () => {
120    const dir = await summaryDir($);
121    if (!dir) return undefined;
122    const iso = new Date(at).toISOString();
123    const stamp = iso.slice(0, 23).replace(/[-:]/g, '').replace('T', '-').replace('.', '');
124    const path = `${dir}/${stamp}-${c.sessionId.replace(/[^\w-]/g, '_')}.md`;
125    const head = `<!-- warm-compact ${VERSION}: session ${c.sessionId}, ${iso}, ${c.manual ? 'manual' : 'idle'} after ${c.idleMinutes} min, ${tokens(result.tokensBefore ?? c.tokensBefore)} -> ${tokens(result.tokensAfter)} tokens -->\n\n`;
126    await $.fs.write(path, `${head}${summary}\n`);
127    return path;
128  };
129  let timeout: Timer | undefined;
130  try {
131    return await Promise.race([
132      write(),
133      new Promise<undefined>(resolve => { timeout = $.clock.after(SAVE_MS, () => resolve(undefined)); }),
134    ]);
135  } catch {
136    return undefined;
137  } finally {
138    timeout?.cancel();
139  }
140}
141
142async function finish($: EngineInterface, c: Compacting, result: SessionCompactResult | undefined, failure?: string) {
143  // A late answer of an attempt given up on must not close a newer one.
144  if (compacting !== c) return;
145  compacting = undefined;
146  // The history was replaced or the attempt is over: nothing to wait out until the next request.
147  if (!c.overtaken) lastRequestAt = undefined;
148  const now = await $.clock.now();
149  if (result && result.skip === undefined) {
150    const share = cachePercent(result.usage);
151    const before = result.tokensBefore ?? c.tokensBefore;
152    const file = await saveSummary($, c, now, result);
153    const e: LogEntry = {
154      ...entry(c, now, 'compacted', c.manual ? 'manual' : 'idle'),
155      tokensBefore: before,
156      tokensAfter: result.tokensAfter,
157      cacheShare: share,
158      summaryInput: result.usage ? result.usage.input_tokens + result.usage.cache_read_input_tokens + result.usage.cache_creation_input_tokens : undefined,
159      summaryOutput: result.usage?.output_tokens,
160      summaryFile: file,
161    };
162    const cache = share === undefined ? '' : say('cacheShare', { pct: share });
163    const saved = file ? say('summarySaved', { file }) : '';
164    await record($, e, say('compacted', { time: clockTime(now), idle: c.idleMinutes, before: tokens(before), after: tokens(result.tokensAfter), cache }) + saved);
165  } else if (result) {
166    await record($, entry(c, now, 'skipped', `vetoed: ${result.skip}`), say('vetoed', { reason: result.skip }));
167  } else {
168    const reason = (failure || 'no compaction seen').slice(0, 300);
169    await record($, entry(c, now, 'failed', reason), say('failed', { reason }));
170  }
171}
172
173/** The engine's refusal of the direct call in an SDK session (the desktop app). */
174export function isHeadlessRefusal(err: unknown) {
175  return /headless|SDK session/i.test(String((err as Error)?.message ?? err));
176}
177
178/** A rate-limit window that is used up and not known to have reset: until it does, requests are refused or billed as extra usage. */
179export function usedUp(limits: readonly SessionRateLimit[], now: number): SessionRateLimit | undefined {
180  // The reading is the last API response's: past its reset time the window is fresh again.
181  return (limits ?? []).find(w => w.percentUsed >= 100 && !(Date.parse(w.resetsAt ?? '') <= now));
182}
183
184/** Why the attempt should not compact any more: the cache went cold while it waited. */
185function lateReason(c: Compacting, now: number): string | undefined {
186  return !c.manual && now - c.since > cfg.cutoffMs ? 'late: the cache went cold while waiting' : undefined;
187}
188
189/** The timer's work: checks again that compacting is due and worth it, then compacts. */
190async function fire($: EngineInterface, token: number, sessionId: string, since: number, manual: boolean) {
191  const stale = (now: number) => token !== generation || working || (compacting !== undefined && now - compacting.startedAt < STALE_MS);
192  const start = await $.clock.now();
193  if (stale(start)) return;
194  const base = { sessionId, idleMinutes: Math.round((start - since) / 60_000), manual };
195  // A skip ends this idle period; the state is cleared only while no newer timer owns it.
196  const skip = async (reason: string, line: string, tokensBefore?: number) => {
197    if (token === generation) await forget($);
198    await record($, entry({ ...base, tokensBefore }, start, 'skipped', reason), line);
199  };
200  if ((await $.session.id()) !== sessionId) return;
201  if (!manual && start - since > cfg.cutoffMs) {
202    await skip('late', say('skipLate', { idle: base.idleMinutes }));
203    return;
204  }
205  const { context, rateLimits } = await $.session.usage();
206  const size = context.tokens;
207  if (!manual && (size ?? 0) < cfg.minTokens) {
208    await skip('small', say('skipSmall', { tokens: tokens(size), min: tokens(cfg.minTokens) }), size);
209    return;
210  }
211  // By hand or not: the summary request would be refused until the reset, or billed as extra usage.
212  const limit = usedUp(rateLimits, start);
213  if (limit) {
214    await skip('limited', say('skipLimited', { kind: limit.kind }), size);
215    return;
216  }
217  if (!manual) {
218    const busy = (await $.agent.list()).filter(a => BUSY_AGENTS.includes(a.status));
219    if (busy.length) {
220      await skip('agents', say('skipAgents', { n: busy.length }), size);
221      return;
222    }
223  }
224  // The reads above took time: the cache must still be warm and no turn started.
225  const now = await $.clock.now();
226  if (stale(now)) return;
227  if (!manual && now - since > cfg.cutoffMs) {
228    await skip('late', say('skipLate', { idle: Math.round((now - since) / 60_000) }));
229    return;
230  }
231  const idleMinutes = Math.round((now - since) / 60_000);
232  const note = instructions(cfg, clockTime(now), idleMinutes, manual);
233  const attempt: Compacting = { token, sessionId, startedAt: now, idleMinutes, tokensBefore: size, manual, since, note };
234  compacting = attempt;
235  timer = undefined;
236  dueAt = undefined;
237  armedSince = undefined;
238  // Cleared before compacting: a reload meanwhile must not start a second one.
239  await forget($);
240  const ours = () => compacting === attempt;
241  if (!ours() || token !== generation || working) {
242    if (ours()) compacting = undefined;
243    return;
244  }
245  try {
246    if (direct !== false) {
247      try {
248        const late = lateReason(attempt, await $.clock.now());
249        if (late) {
250          await finish($, attempt, { skip: late });
251          return;
252        }
253        const result = await $.session.compact({ instructions: note });
254        direct = true;
255        await finish($, attempt, result);
256        return;
257      } catch (err) {
258        if (!isHeadlessRefusal(err)) throw err;
259        direct = false;
260      }
261      if (!ours() || token !== generation || working) {
262        if (ours()) compacting = undefined;
263        return;
264      }
265    }
266    // An SDK session compacts inside a turn: run /compact as if typed. It is
267    // queued until the session is idle; the compaction passes through this
268    // module's `session.compact` hook, which records it.
269    const output = await $.command.run({ command: 'compact', args: note });
270    await finish($, attempt, undefined, `/compact ran without compacting: ${output.text ?? '(no output)'}`);
271  } catch (err) {
272    await finish($, attempt, undefined, String((err as Error)?.message ?? err));
273  }
274}
275
276function schedule($: EngineInterface, sessionId: string, since: number, now: number) {
277  const token = generation;
278  dueAt = since + cfg.idleMs;
279  armedSince = since;
280  timer = $.clock.after(Math.max(0, dueAt - now), () => {
281    void fire($, token, sessionId, since, false).catch(() => {});
282  });
283}
284
285/** At the end of a main-loop turn: arms the timer when the context is large enough. */
286async function arm($: EngineInterface) {
287  disarm();
288  const token = generation;
289  const since = lastRequestAt;
290  if (since === undefined) return;
291  // Headless runs (-p, an SDK script, a bot) draw nowhere: leave them alone.
292  if (!(await $.session.surfaces()).length) return;
293  const sessionId = await $.session.id();
294  if (await isOff($, sessionId)) return;
295  const { context } = await $.session.usage();
296  const now = await $.clock.now();
297  if (token !== generation) return;
298  if ((context.tokens ?? 0) < cfg.minTokens) {
299    await forget($);
300    return;
301  }
302  schedule($, sessionId, since, now);
303  // Kept for a reload of the module, which drops its timers.
304  await $.state.set(ARMED, { sessionId, lastRequestAt: since });
305  // Overtaken while writing: a turn.start may have cleared the state before this
306  // write landed, or a newer arm written its own; leave what the newer one owns.
307  if (token !== generation) {
308    if (armedSince === undefined) await forget($);
309    else await $.state.set(ARMED, { sessionId, lastRequestAt: armedSince }).catch(() => {});
310  }
311}
312
313/** After a reload: arms again for what is left since this session's last request. */
314async function rearm($: EngineInterface) {
315  disarm();
316  const token = generation;
317  const { value } = await $.state.get(ARMED);
318  const sessionId = await $.session.id();
319  const now = await $.clock.now();
320  if (token !== generation || !value || value.sessionId !== sessionId) return;
321  if (await isOff($, sessionId)) return;
322  if (token !== generation) return;
323  lastRequestAt = value.lastRequestAt;
324  schedule($, sessionId, value.lastRequestAt, now);
325}
326
327async function statusText($: EngineInterface) {
328  const sessionId = await $.session.id();
329  const off = await isOff($, sessionId);
330  const now = await $.clock.now();
331  let armed: string;
332  if (off) armed = say('notArmed', { why: say('offHere') });
333  else if (compacting && now - compacting.startedAt < STALE_MS) armed = say('compactingNow');
334  else if (working) armed = say('working');
335  else if (dueAt !== undefined) {
336    const { context } = await $.session.usage();
337    armed = say('armedAt', { time: clockTime(dueAt), tokens: tokens(context.tokens) });
338  } else if (lastRequestAt === undefined) armed = say('notArmed', { why: say('noRequest') });
339  else {
340    const { context } = await $.session.usage();
341    armed = say('notArmed', { why: say('small', { tokens: tokens(context.tokens), min: tokens(cfg.minTokens) }) });
342  }
343  const lines = [say('status', { version: VERSION, state: say(off ? 'off' : 'on'), idle: cfg.idleMs / 60_000, cutoff: cfg.cutoffMs / 60_000, min: tokens(cfg.minTokens), armed })];
344  const saved = await $.store.get(LOG_KEY);
345  const mine = (Array.isArray(saved) ? (saved as LogEntry[]) : []).filter(e => e.sessionId === sessionId).slice(-5);
346  if (!mine.length) lines.push(say('none'));
347  else {
348    lines.push(say('recent'));
349    for (const e of mine) {
350      const outcome = say(e.outcome === 'compacted' ? 'outcomeCompacted' : e.outcome === 'skipped' ? 'outcomeSkipped' : 'outcomeFailed');
351      const sizes = e.tokensAfter === undefined ? tokens(e.tokensBefore) : `${tokens(e.tokensBefore)} → ${tokens(e.tokensAfter)}`;
352      lines.push(`- ${clockTime(Date.parse(e.at))} ${outcome} (${e.reason}), ${e.idleMinutes} min, ${sizes}`);
353    }
354  }
355  return lines.join('\n');
356}
357
358async function control($: EngineInterface, raw: string): Promise<{ text: string }> {
359  const args = raw.trim().toLowerCase() || 'status';
360  const sessionId = await $.session.id();
361  if (args === 'off') {
362    await $.store.set(`off:${sessionId}`, true);
363    // A /compact still queued does not run after the mod said it is off.
364    if (compacting && !compacting.overtaken) compacting.overtaken = 'turned off';
365    disarm();
366    await forget($);
367    return { text: say('turnedOff') };
368  }
369  if (args === 'on') {
370    await $.store.delete(`off:${sessionId}`);
371    if (!working && !compacting) await arm($);
372    return { text: say('turnedOn') };
373  }
374  if (args === 'now') {
375    const now = await $.clock.now();
376    if (working || (compacting && now - compacting.startedAt < STALE_MS)) return { text: say('busy') };
377    disarm();
378    const token = generation;
379    const since = lastRequestAt ?? now;
380    // Outside this hook: a compaction cannot run inside the command's own dispatch.
381    timer = $.clock.after(1000, () => {
382      void fire($, token, sessionId, since, true).catch(() => {});
383    });
384    return { text: say('nowScheduled') };
385  }
386  if (args !== 'status') return { text: say('commands') };
387  return { text: await statusText($) };
388}
389
390export const register: Register = (on, options) => {
391  cfg = settings(options);
392
393  // Also fires when the module is reloaded (/reload-plugins, an update, an
394  // options change), which drops the timer: pick up where the last request left off.
395  on('session.start', async ($, e, next) => {
396    const result = await next(e);
397    // Not a terminal REPL: an SDK host (the desktop app) or -p, which refuse the direct call.
398    direct = e.isInteractive && e.surface === 'terminal' ? undefined : false;
399    try {
400      await $.command.register({ name: 'warm-compact', description: say('commandDescription'), argumentHint: 'status | now | off | on', immediate: true });
401      await rearm($);
402    } catch {}
403    return result;
404  });
405
406  on('command.run', { command: 'warm-compact' }, ($, e) => control($, e.args));
407
408  on('prompt.submit', async ($, e, next) => {
409    // Desktop may send a custom slash command as client-composed text.
410    // Only a standalone command from the human-facing transport is local.
411    const local = /^\/warm-compact(?:[ \t]+([^\r\n]*))?[ \t]*$/.exec(e.text);
412    if (local && !e.attachments?.length && ['composer', 'sdk', 'bridge'].includes(e.origin.kind)) {
413      try {
414        return { drop: (await control($, local[1] ?? 'status')).text };
415      } catch (err) {
416        return { drop: `warm-compact: ${String((err as Error)?.message ?? err)}` };
417      }
418    }
419    return next(e);
420  });
421
422  // The cache's hour runs from the start of the last request, not from the end
423  // of the answer. The summarizer runs as a fork of its own (agentId set).
424  on('turn.step', async function* ($, e, next) {
425    if (e.agentId === undefined) {
426      const now = await $.clock.now().catch(() => undefined);
427      if (now !== undefined) lastRequestAt = now;
428      // The main loop is busy again: a /compact still queued has nothing to do.
429      if (compacting && !compacting.overtaken) compacting.overtaken = 'a turn ran first';
430    }
431    return yield* next(e);
432  });
433
434  on('turn.start', async ($, e, next) => {
435    if (e.agentId === undefined) {
436      disarm();
437      working = true;
438      await forget($);
439    }
440    return next(e);
441  });
442
443  on('turn.complete', async ($, e, next) => {
444    const armed = generation;
445    const result = await next(e);
446    // A turn that started meanwhile owns the state now.
447    if (e.agentId !== undefined || armed !== generation) return result;
448    working = false;
449    // Never let arming the timer break the turn.
450    await arm($).catch(() => {});
451    return result;
452  });
453
454  on('session.compact', async ($, e, next) => {
455    if (e.agentId !== undefined || e.trigger === 'precompute') return next(e);
456    // Another hook may have added to the instructions on the way: the mod's text stays inside.
457    const attempt = compacting !== undefined && e.instructions?.includes(compacting.note) ? compacting : undefined;
458    if (attempt) {
459      const reason = attempt.overtaken ?? lateReason(attempt, await $.clock.now().catch(() => attempt.startedAt));
460      if (reason) {
461        const skipped = { skip: reason };
462        await finish($, attempt, skipped).catch(() => {});
463        return skipped;
464      }
465    }
466    const result = await next(e);
467    if (attempt) {
468      // This module's own /compact in an SDK session arrives here.
469      await finish($, attempt, result).catch(() => {});
470    } else if (result.skip === undefined) {
471      // Someone else compacted (/compact, the threshold): the history was replaced.
472      if (compacting) compacting.overtaken = 'compacted by someone else first';
473      disarm();
474      lastRequestAt = undefined;
475      await forget($);
476    }
477    return result;
478  });
479
480  on('session.end', async ($, e, next) => {
481    // Also a /clear: the conversation ends and the process goes on under a new id.
482    disarm();
483    lastRequestAt = undefined;
484    working = false;
485    compacting = undefined;
486    await forget($);
487    return next(e);
488  });
489};
490
hooks/text.ts 137 lines
1// Settings from the manifest's userConfig, resolved once per load, and every
2// text the mod shows or hands to the summarizer, in the configured language.
3import type { ModelUsage, PluginOptions } from 'claude-code';
4
5export type Config = {
6  lang: 'en' | 'ru';
7  idleMs: number;
8  cutoffMs: number;
9  minTokens: number;
10  hint: string;
11};
12
13const DEFAULT_IDLE = 52;
14const DEFAULT_CUTOFF = 57;
15const DEFAULT_MIN_TOKENS = 200_000;
16const MAX_IDLE = 58;
17const MAX_CUTOFF = 59;
18
19function number(raw: unknown): number | undefined {
20  if (raw === undefined || raw === '') return undefined;
21  const n = Number(raw);
22  return Number.isFinite(n) && n > 0 ? n : undefined;
23}
24
25export function settings(options: PluginOptions = {}): Config {
26  // The cache lives an hour: both times stay under it, the cutoff after the idle time.
27  let idle = number(options.idleMinutes) ?? DEFAULT_IDLE;
28  if (idle > MAX_IDLE) idle = DEFAULT_IDLE;
29  let cutoff = number(options.cutoffMinutes) ?? DEFAULT_CUTOFF;
30  if (cutoff <= idle || cutoff > MAX_CUTOFF) cutoff = Math.min(Math.max(DEFAULT_CUTOFF, idle + 1), MAX_CUTOFF);
31  return {
32    lang: options.language === 'ru' ? 'ru' : 'en',
33    idleMs: idle * 60_000,
34    cutoffMs: cutoff * 60_000,
35    minTokens: number(options.minTokens) ?? DEFAULT_MIN_TOKENS,
36    hint: String(options.noteHint ?? '').trim(),
37  };
38}
39
40const MESSAGES = {
41  en: {
42    commandDescription: 'Compact this session when it idles before the prompt cache expires: status, now, off, on',
43    commands: 'Commands: /warm-compact status, /warm-compact now, /warm-compact off, /warm-compact on.',
44    status: 'warm-compact {version}: {state} in this session. Compacts {idle} min after the last model request when the context is at least {min} tokens; a timer later than {cutoff} min skips. {armed}',
45    on: 'on',
46    off: 'off',
47    armedAt: 'Armed: compacts at {time} ({tokens} tokens now).',
48    working: 'A turn is running: the timer is armed when it ends.',
49    compactingNow: 'Compacting now.',
50    notArmed: 'Not armed: {why}.',
51    noRequest: 'no model request since the mod loaded',
52    small: 'the context is {tokens} tokens, under {min}',
53    offHere: 'off in this session',
54    recent: 'Recent in this session:',
55    none: 'Nothing compacted or skipped in this session yet.',
56    turnedOff: 'warm-compact is off in this session.',
57    turnedOn: 'warm-compact is on in this session.',
58    busy: 'A turn is running. Run /warm-compact now when it ends.',
59    nowScheduled: 'Compacting in a moment.',
60    compacted: 'warm-compact: compacted at {time} after {idle} min idle, {before} tokens → {after}{cache}.',
61    cacheShare: ', {pct}% of the summary input read from cache',
62    summarySaved: ' Summary saved: {file}',
63    skipLate: 'warm-compact: not compacted, the timer fired {idle} min after the last request (the computer slept?): the cache is cold already.',
64    skipSmall: 'warm-compact: not compacted, the context is {tokens} tokens, under {min}.',
65    skipAgents: 'warm-compact: not compacted, {n} agent(s) still running.',
66    skipLimited: 'warm-compact: not compacted, the {kind} usage limit is used up and has not reset yet.',
67    vetoed: 'warm-compact: compaction skipped: {reason}.',
68    failed: 'warm-compact: compaction failed: {reason}.',
69    outcomeCompacted: 'compacted',
70    outcomeSkipped: 'skipped',
71    outcomeFailed: 'failed',
72    instructions: 'This compaction ran automatically at {time}, after {idle} minutes without a model request, before the one-hour prompt cache expired. Open the summary by saying so. Write it as a note for continuing the work: the goal; what is done; what is left and the next step; files touched; decisions and permissions the user gave, with their reasons; rejected options, briefly, and why they were rejected; the exact paths, file names, commands, commits, identifiers and numbers needed to continue and verify the work, not rounded or paraphrased, leaving out intermediate and repeated ones; background processes, agents and waits still open; what was not verified; dates and deadlines.',
73    instructionsManual: 'This compaction was started by hand with /warm-compact now at {time}. Open the summary by saying so. Write it as a note for continuing the work: the goal; what is done; what is left and the next step; files touched; decisions and permissions the user gave, with their reasons; rejected options, briefly, and why they were rejected; the exact paths, file names, commands, commits, identifiers and numbers needed to continue and verify the work, not rounded or paraphrased, leaving out intermediate and repeated ones; background processes, agents and waits still open; what was not verified; dates and deadlines.',
74  },
75  ru: {
76    commandDescription: 'Сжимать сессию в простое до протухания кэша: status, now, off, on',
77    commands: 'Команды: /warm-compact status, /warm-compact now, /warm-compact off, /warm-compact on.',
78    status: 'warm-compact {version}: в этой сессии {state}. Сжимает через {idle} мин после последнего запроса к модели, если контекст не меньше {min} токенов; таймер позже {cutoff} мин пропускает. {armed}',
79    on: 'включён',
80    off: 'выключен',
81    armedAt: 'Взведён: сожмёт в {time} (сейчас {tokens} токенов).',
82    working: 'Идёт ход: таймер взведётся по его окончании.',
83    compactingNow: 'Идёт сжатие.',
84    notArmed: 'Не взведён: {why}.',
85    noRequest: 'с загрузки мода не было запросов к модели',
86    small: 'контекст {tokens} токенов, меньше {min}',
87    offHere: 'выключен в этой сессии',
88    recent: 'Последнее в этой сессии:',
89    none: 'В этой сессии ещё ничего не сжималось и не пропускалось.',
90    turnedOff: 'warm-compact выключен в этой сессии.',
91    turnedOn: 'warm-compact включён в этой сессии.',
92    busy: 'Идёт ход. Запустите /warm-compact now, когда он закончится.',
93    nowScheduled: 'Сжатие через мгновение.',
94    compacted: 'warm-compact: сжато в {time} после {idle} мин тишины, {before} токенов → {after}{cache}.',
95    cacheShare: ', {pct}% входа резюме прочитано из кэша',
96    summarySaved: ' Сводка сохранена: {file}',
97    skipLate: 'warm-compact: не сжато, таймер сработал через {idle} мин после последнего запроса (компьютер спал?): кэш уже остыл.',
98    skipSmall: 'warm-compact: не сжато, контекст {tokens} токенов, меньше {min}.',
99    skipAgents: 'warm-compact: не сжато, ещё работают агенты: {n}.',
100    skipLimited: 'warm-compact: не сжато, лимит {kind} исчерпан и ещё не сброшен.',
101    vetoed: 'warm-compact: сжатие пропущено: {reason}.',
102    failed: 'warm-compact: сжатие не удалось: {reason}.',
103    outcomeCompacted: 'сжато',
104    outcomeSkipped: 'пропущено',
105    outcomeFailed: 'не удалось',
106    instructions: 'Это сжатие запущено автоматически в {time}, после {idle} минут без запросов к модели, до протухания часового промт-кэша. Начни резюме с этого. Пиши её как записку для продолжения работы: цель; что сделано; что осталось и следующий шаг; тронутые файлы; решения и разрешения пользователя с причинами; отвергнутые варианты, коротко, и почему от них отказались; точные пути, имена файлов, команды, коммиты, идентификаторы и числа, нужные для продолжения и проверки, без округления и пересказа, а промежуточные и повторы опускай; незавершённые фоновые процессы, агенты и ожидания; что не проверено; даты и сроки.',
107    instructionsManual: 'Это сжатие запущено вручную командой /warm-compact now в {time}. Начни резюме с этого. Пиши её как записку для продолжения работы: цель; что сделано; что осталось и следующий шаг; тронутые файлы; решения и разрешения пользователя с причинами; отвергнутые варианты, коротко, и почему от них отказались; точные пути, имена файлов, команды, коммиты, идентификаторы и числа, нужные для продолжения и проверки, без округления и пересказа, а промежуточные и повторы опускай; незавершённые фоновые процессы, агенты и ожидания; что не проверено; даты и сроки.',
108  },
109} as const;
110
111export type MessageKey = keyof typeof MESSAGES.en;
112
113export function t(cfg: Config, key: MessageKey, vars: Record<string, string | number> = {}): string {
114  return MESSAGES[cfg.lang][key].replace(/\{(\w+)\}/g, (all, name: string) => (name in vars ? String(vars[name]) : all));
115}
116
117export function clockTime(ms: number): string {
118  return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(ms);
119}
120
121export function tokens(n: number | undefined): string {
122  if (n === undefined) return '?';
123  return n < 1000 ? String(n) : `${Math.round(n / 1000)}k`;
124}
125
126/** Share of the summary request's input served by the prompt cache, 0 to 100. */
127export function cachePercent(usage: ModelUsage | undefined): number | undefined {
128  if (!usage) return undefined;
129  const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
130  return input ? Math.floor((usage.cache_read_input_tokens * 100) / input) : undefined;
131}
132
133export function instructions(cfg: Config, time: string, idleMinutes: number, manual: boolean): string {
134  const base = t(cfg, manual ? 'instructionsManual' : 'instructions', { time, idle: idleMinutes });
135  return cfg.hint ? `${base} ${cfg.hint}` : base;
136}
137
types/index.d.ts 16 lines
1// What warm-compact keeps in $.state: the armed timer of the session, so a
2// reload of the module (which drops its timers) can arm it again.
3declare module 'claude-code' {
4  interface PluginState {
5    'warm-compact': {
6      armed: WarmCompactArmed | null;
7    };
8  }
9}
10
11export type WarmCompactArmed = {
12  sessionId: string;
13  // When the main loop last sent a request to the model, in $.clock.now() ms.
14  lastRequestAt: number;
15};
16