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

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.
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.
idleMinutes of silence, if the context is at least minTokens, the session is compacted.cutoffMinutes after the last request (for example the computer was asleep), the cache is already cold and the mod does nothing./clear, or a compaction done by someone else cancels the timer./compact as a queued command. In a terminal it calls the compaction directly./compact starts, the mod drops its compaction: the cache is warm again or the context is small already.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.
| Command | Effect |
|---|---|
/warm-compact status | Version, session and state |
/warm-compact now | Compact at once, ignoring idle time and size; refused while a turn runs, skipped while the usage limit is used up |
/warm-compact off | Turn the mod off in this session |
/warm-compact on | Turn it back on in this session |
off and on apply to the current session only.
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": "" }
}
}
}
| Option | Default | Meaning |
|---|---|---|
idleMinutes | 52 | Minutes after the last model request before compaction; keep it a few minutes under the one-hour cache |
cutoffMinutes | 57 | A timer that fires later than this after the last request skips, because the cache is cold already |
minTokens | 200000 | Context size below which a cold re-read is cheap and the session is left as it is |
language | en | en or ru: the mod's messages and its instructions to the summary |
noteHint | empty | Extra text added to the summary instructions, for example what else to keep |
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 }
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.
Tested on Claude Code 2.1.308.
claude plugin validate --strict .
claude plugin test .
MIT License.
hooks/register.ts 490 lines1import 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};
490hooks/text.ts 137 lines1// 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}
137types/index.d.ts 16 lines1// 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