/** * Per-session cache-liveness state for the history collapse. * * ## Why this exists * * The history grid is append-only: chunk N's pixels are a pure function of its * message range, so old chunks stay byte-identical as the conversation grows and * ride Anthropic's prompt cache as `cache_read` forever. That freeze is worth a * lot — but only while a cache actually exists. Where there is none, the freeze * protects nothing and the grid is free to be re-cut for *density* instead: * {@link HistoryCollapseOptions.packFill} raises the freeze step until the pages * are nearly full, which roughly halves image tokens on long sessions of short * turns (#161: 317 images at 43% fill). * * ## How we know whether a cache exists * * The provider tells us, and that beats inferring it. {@link noteCacheOutcome} * feeds each response's accounting back in: a `cache_read` proves the prefix was * live, a `cache_create` proves one was just written. Only when both are zero is * there nothing to lose by re-cutting. * * This module used to decide from the wall clock alone, treating any gap past the * ephemeral 5-minute TTL as cold. That was wrong most of the times it fired — * measured over 143 gaps on a production host, the cache was still warm in 66% of * gaps past 5.5 minutes, 40% past 15 minutes, 13% past an hour. Claude Code marks * some blocks with the 1-hour TTL, so the short constant never described this * traffic. The worst case was a session repacked three times in one hour on * ~10-minute gaps, each repack re-keying the whole prefix — and each preceded by a * turn that had just *written* a cache, which the clock could not see. * * The clock survives only as a backstop for responses whose accounting never * arrived: {@link COLD_HORIZON_MS}. And a rejected request still marks the session * dead outright ({@link markCacheDead}) — but only a 413 or a too-long 400, not * a transient 5xx: see {@link responseLeftNoCache}. * * ## Why the step is sticky * * Once a session has been repacked coarse, every later turn must keep at least * that step. Falling back to the fine grid would re-cut the same messages into * different chunks — every chunk's bytes change, and the whole history re-keys as * `cache_create`. {@link recordFreezeStep} pins the floor; the collapse only ever * doubles it. * * ## Failure mode we deliberately accept * * State is in-memory and per proxy process. After a restart a live session looks * *unknown*, and unknown is treated as WARM (no repack) — the conservative * choice: at worst we keep paying the old image count, we never nuke a live cache * on a guess. The state re-arms itself on the first idle gap after the restart. */ export interface HistorySessionState { /** The upstream prefix cache is provably gone — re-cutting the grid is free. */ cold: boolean; /** Floor for the freeze step, in messages. 0 = no constraint. */ minFreezeStep: number; } /** * Record a request for `sessionKey` and report what the history collapse may * assume about the upstream cache. Call once per transformed request, BEFORE the * collapse runs; it advances the session's last-seen clock. * * A session we have never seen counts as warm (see module docs) — unknown must * never authorize a repack. */ export declare function noteHistoryRequest(sessionKey: string | undefined, nowMs?: number): HistorySessionState; /** * Feed the provider's cache accounting back in, once per response. * * `read > 0` proves the prefix was live. `create > 0` proves one was just * written, which is the case the wall clock got wrong: a turn that paid to build * a cache looks identical to a turn that found none, and repacking on top of it * discards the thing just paid for. * * Both zero means nothing is cached for this session, so a repack costs nothing * — that is the only situation where coarsening the grid is free. */ export declare function noteCacheOutcome(sessionKey: string | undefined, cacheReadTokens: number | undefined, cacheCreateTokens: number | undefined): void; /** * Pin the grid this session was last rendered at. Monotonic: the floor only ever * rises, because a later, finer render would re-key every chunk it re-cuts. */ export declare function recordFreezeStep(sessionKey: string | undefined, step: number | undefined): void; /** * Mark this session's upstream cache as gone: the last request was rejected, so * nothing was cached and the next one may re-cut the grid for density. Call on * the failure paths that leave no cache entry (oversized request → opaque 500). */ export declare function markCacheDead(sessionKey: string | undefined): void; /** * Did this response leave the upstream prefix cache unpopulated? * * A cache entry is written by a request the provider actually *accepted*. Three * outcomes mean it never got that far, so the frozen grid we were protecting * protects nothing and the next turn may re-cut for density: * * - `413` — the payload was rejected outright; * - `400` whose body says the prompt is too long (Anthropic's wording varies: * `prompt is too long`, `prompt_too_long`, `request_too_large`). * * NOT any 5xx, which is what this used to say. Production disagreed: of 20871 * requests on one host the 5xx population was 177 × `529 overloaded`, 2 × `500` * and 1 × `503` — and 129 of 250 repacks fired directly after one of them. A 529 * means the provider declined to process the request; the prefix cache it never * touched is still there, and re-cutting the grid threw it away for nothing. * * Nor any other 4xx. A bad key or a rate limit says nothing about the cache. * * A cache that genuinely died needs no error to be noticed: {@link * noteCacheOutcome} sees the next response report neither a read nor a write, and * that is both accurate and free. */ export declare function responseLeftNoCache(status: number, errorBody?: string): boolean; /** Test seam: drop all session state. */ export declare function resetSessionState(): void; /** Test/telemetry seam: inspect a session without mutating its clock. */ export declare function peekSessionState(sessionKey: string): { lastSeenMs: number; freezeStep: number; cacheDead: boolean; } | undefined; //# sourceMappingURL=session-state.d.ts.map