SLOPSHOPPER

cold-cache-guard

Compact large conversations before a prompt rewrites an expired cache.

toastpromptprocesstimer
A shopper browsing a rack in a slop shop
README

dotfiles

Source 2 files
hooks/register.ts 266 lines
1import { update } from "claude-code";
2import type { EngineInterface, Register, Timer } from "claude-code";
3import type { ColdCacheGuardTranscriptPath } from "../types/index";
4
5const MIB = 1024 * 1024;
6const transcriptPath = { plugin: "cold-cache-guard", key: "transcriptPath" } as const;
7const pendingPrompts = { plugin: "cold-cache-guard", key: "pendingPrompts" } as const;
8
9function isObject(value: unknown): value is Record<string, unknown> {
10    return value !== null && typeof value === "object" && !Array.isArray(value);
11}
12
13function requireString(value: unknown) {
14    if (typeof value !== "string") throw new Error("Expected a string");
15    return value;
16}
17
18function nonnegativeNumber(value: unknown) {
19    if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
20        throw new Error("Expected a nonnegative number");
21    }
22    return value;
23}
24
25type AssistantEntry = Record<string, unknown> & { message: Record<string, unknown> };
26
27function qualifies(entry: unknown): entry is AssistantEntry {
28    return isObject(entry) && entry.type === "assistant" && entry.isSidechain !== true &&
29        isObject(entry.message) && entry.message.model !== undefined &&
30        entry.message.model !== "<synthetic>" && entry.message.usage !== undefined;
31}
32
33function usageOf(entry: AssistantEntry) {
34    const usage = entry.message.usage;
35    if (!isObject(usage)) throw new Error("Invalid usage");
36    return usage;
37}
38
39function tokenCount(usage: Record<string, unknown>, field: string) {
40    return usage[field] === undefined ? 0 : nonnegativeNumber(usage[field]);
41}
42
43function writeTtl(entry: AssistantEntry) {
44    const usage = usageOf(entry);
45    const creation = usage.cache_creation === undefined ? {} : usage.cache_creation;
46    if (!isObject(creation)) throw new Error("Invalid cache creation");
47    const hour = tokenCount(creation, "ephemeral_1h_input_tokens");
48    const short = tokenCount(creation, "ephemeral_5m_input_tokens");
49    if (hour > 0) return 3600;
50    if (short > 0) return 300;
51    if (tokenCount(usage, "cache_creation_input_tokens") > 0) return 3600;
52    return null;
53}
54
55export function scanTail(text: string, now: number) {
56    let last: AssistantEntry | null = null;
57    let compactAfterLast = false;
58    let ttl = null;
59    let end = text.length;
60    while (end > 0) {
61        const start = text.lastIndexOf("\n", end - 1) + 1;
62        const line = text.slice(start, end);
63        end = start - 1;
64        let entry: unknown;
65        try {
66            entry = JSON.parse(line);
67        } catch {
68            continue;
69        }
70        if (!last && isObject(entry) && entry.type === "system" && entry.subtype === "compact_boundary") {
71            compactAfterLast = true;
72        }
73        if (!qualifies(entry)) continue;
74        if (!last) last = entry;
75        ttl = writeTtl(entry);
76        if (ttl !== null) break;
77    }
78    if (!last) return null;
79    const usage = usageOf(last);
80    const tokens = nonnegativeNumber([
81        "input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens",
82    ].reduce((total, field) => total + tokenCount(usage, field), 0));
83    const timestamp = Date.parse(requireString(last.timestamp));
84    if (!Number.isFinite(timestamp)) throw new Error("Invalid timestamp");
85    const model = requireString(last.message.model);
86    if (!model) throw new Error("Missing model");
87    return { tokens, model, idle: (now - timestamp) / 1000, ttl: ttl ?? 3600, compactAfterLast };
88}
89
90// $.fs has no ranged reads. Stage the bounded window in chunks below its 4 MiB cap.
91const stageTail = `set -eu
92directory=$(/usr/bin/mktemp -d "\${TMPDIR:-/tmp}/cold-cache-guard.XXXXXXXX")
93trap '/bin/rm -rf "$directory"' 0
94/bin/dd if="$1" of="$directory/window" bs=1048576 skip="$(($2 / 1048576))" count="$((($2 % 1048576 + $3 + 1048575) / 1048576))" 2>/dev/null
95/usr/bin/tail -c "+$(($2 % 1048576 + 1))" "$directory/window" | /usr/bin/head -c "$3" | /usr/bin/split -b 1048576 - "$directory/chunk-"
96printf '%s' "$directory"
97trap - 0`;
98
99export async function readTail($: EngineInterface, transcript: string, size: number, limit: number) {
100    const start = Math.max(0, size - limit);
101    let text;
102    if (size <= 4 * MIB) {
103        text = await $.fs.read(transcript);
104    } else {
105        const staged = await $.process.run([
106            "/bin/sh", "-c", stageTail, "cold-cache-guard", transcript, String(start), String(size - start),
107        ]);
108        if (staged.exitCode !== 0) throw new Error("Could not stage transcript tail");
109        const directory = staged.stdout;
110        try {
111            const entries = (await $.fs.list(directory)).filter((entry) => entry.name.startsWith("chunk-"));
112            entries.sort((left, right) => left.name.localeCompare(right.name));
113            const bytes = new Uint8Array(size - start);
114            let offset = 0;
115            for (const entry of entries) {
116                const { base64 } = await $.fs.read(`${directory}/${entry.name}`, { as: "bytes" });
117                const chunk = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
118                bytes.set(chunk, offset);
119                offset += chunk.length;
120            }
121            text = new TextDecoder().decode(bytes.subarray(0, offset));
122        } finally {
123            await $.process.run(["/bin/rm", "-rf", directory]);
124        }
125    }
126    if (!start) return text;
127    const newline = text.indexOf("\n");
128    return newline === -1 ? "" : text.slice(newline + 1);
129}
130
131async function readTranscript($: EngineInterface, transcript: string, now: number) {
132    const info = await $.fs.stat(transcript);
133    if (info.kind !== "file") throw new Error("Not a transcript file");
134    let result = scanTail(await readTail($, transcript, info.size, 4 * MIB), now);
135    if (!result) result = scanTail(await readTail($, transcript, info.size, 32 * MIB), now);
136    return result;
137}
138
139async function consumeForce($: EngineInterface) {
140    const home = await $.env.get("HOME");
141    if (!home) return false;
142    const file = `${home}/.cache/cold-cache-guard/force-once`;
143    if (!await $.fs.exists(file)) return false;
144    const result = await $.process.run(["/bin/rm", file]);
145    if (result.exitCode !== 0) throw new Error("Could not consume force-once");
146    return true;
147}
148
149export function formatIdle(seconds: number) {
150    const minutes = Math.max(0, Math.floor(seconds / 60));
151    const hours = Math.floor(minutes / 60);
152    if (hours >= 48) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
153    if (hours) return `${hours}h ${minutes % 60}m`;
154    return `${minutes}m`;
155}
156
157export function formatTokens(tokens: number) {
158    return tokens >= 1000000 ? `~${(tokens / 1000000).toFixed(1)}M` : `~${Math.round(tokens / 1000)}k`;
159}
160
161function oneLine(reason: unknown) {
162    return String(reason instanceof Error ? reason.message : reason).replace(/[\r\n]+/g, " ");
163}
164
165export const register: Register = (on, options) => {
166    let schedule: (() => void) | null = null;
167    let timer: Timer | null = null;
168    let busy = false;
169    let requested = false;
170    const delivered = new Set<string>();
171
172    on("session.start", async ($, e, next) => {
173        const drain = async () => {
174            timer = null;
175            if (busy) return;
176            busy = true;
177            requested = false;
178            try {
179                const { value = [] } = await $.state.get(pendingPrompts);
180                const job = value[0];
181                if (!job) return;
182                try {
183                    if (!job.compactionAttempted) {
184                        await update($, pendingPrompts, (jobs) => (jobs ?? []).map((pending) =>
185                            pending.id === job.id ? { ...pending, compactionAttempted: true } : pending));
186                        // Headless (VS Code, SDK, -p) rejects $.session.compact() in Claude Code 2.1.310; /compact via $.command.run works (verified 26-10-2026).
187                        if (e.isInteractive) {
188                            const result = await $.session.compact();
189                            if (result.skip !== undefined) {
190                                $.ui.toast(`Compaction skipped: ${oneLine(result.skip)}; sending your prompt`);
191                            }
192                        } else {
193                            await $.command.run({ command: "compact" });
194                        }
195                    }
196                } catch (error) {
197                    $.ui.toast(`Compaction failed: ${oneLine(error)}; sending your prompt`);
198                } finally {
199                    if (!delivered.has(job.id)) {
200                        const result = await $.prompt.submit({
201                            text: job.prompt.text,
202                            attachments: job.prompt.attachments,
203                            asUser: true,
204                        });
205                        if ("drop" in result) throw new Error(result.drop);
206                        delivered.add(job.id);
207                    }
208                    let more = false;
209                    await update($, pendingPrompts, (jobs) => {
210                        const remaining = (jobs ?? []).filter((pending) => pending.id !== job.id);
211                        more = remaining.length > 0;
212                        return remaining;
213                    });
214                    delivered.delete(job.id);
215                    if (more) timer = $.clock.after(0, drain);
216                }
217            } catch (error) {
218                try {
219                    $.ui.toast(`Prompt still queued: ${oneLine(error)}; retrying automatically`);
220                } catch {}
221                timer = $.clock.after(1000, drain);
222            } finally {
223                busy = false;
224                if (requested && !timer) schedule?.();
225            }
226        };
227        schedule = () => {
228            requested = true;
229            if (!timer && !busy) timer = $.clock.after(0, drain);
230        };
231        if ((await $.state.get(pendingPrompts)).value?.length) schedule();
232        return next(e);
233    });
234
235    on("classic.SessionStart", async ($, e, next) => {
236        await $.state.set(transcriptPath, e.transcript_path);
237        return next(e);
238    });
239
240    on("prompt.submit", async ($, e, next) => {
241        try {
242            if (e.command || e.isMeta || e.turnId !== undefined ||
243                !["composer", "bridge", "sdk"].includes(e.origin.kind)) return next(e);
244            const forced = await consumeForce($);
245            const { value = null } = await $.state.get(transcriptPath);
246            const path: ColdCacheGuardTranscriptPath = value;
247            if (!path) return next(e);
248            const session = await readTranscript($, path, await $.clock.now());
249            if (!session || session.compactAfterLast) return next(e);
250            const margin = session.ttl === 3600 ? 120 : 30;
251            if (!forced && (session.idle <= session.ttl - margin || session.tokens < 150000)) return next(e);
252            // This build's plugin submit bridge drops media even when attachments are passed.
253            if (e.attachments?.length) {
254                $.ui.toast("Compaction unavailable for attached prompts in this build; sending your prompt with attachments");
255                return next(e);
256            }
257            if (!schedule) return next(e);
258            $.ui.toast(`Cache expired (idle ${formatIdle(session.idle)}, ${formatTokens(session.tokens)} tokens): compacting before your prompt`);
259            await update($, pendingPrompts, (jobs) => [...(jobs ?? []), { id: crypto.randomUUID(), prompt: e }]);
260            schedule();
261            return { drop: "Compacting first; your prompt follows automatically." };
262        } catch {}
263        return next(e);
264    }).catch(($, e, next) => next(e));
265};
266
types/index.d.ts 11 lines
1export type ColdCacheGuardTranscriptPath = string | null;
2
3declare module "claude-code" {
4    interface PluginState {
5        "cold-cache-guard": {
6            transcriptPath: ColdCacheGuardTranscriptPath;
7            pendingPrompts: { id: string; prompt: PromptSubmitInput; compactionAttempted?: boolean }[];
8        };
9    }
10}
11