import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { createHash } from "node:crypto"; import { getHudPluginDir } from "./claude-config-dir.js"; import { createDebug } from "./debug.js"; const debug = createDebug('context-cache'); const CACHE_DIRNAME = "context-cache"; /** * Minimum interval between cache rewrites for the same session. * Status line refreshes are event-driven and can fire in rapid bursts * (debounced at 300ms), so this keeps the steady-state write path cheap * while still refreshing the fallback snapshot regularly. */ const WRITE_TTL_MS = 3_000; /** * Sweep parameters bounding long-term growth of the cache directory. * A sweep is attempted probabilistically on cache writes to avoid paying * directory-scan cost on every status line tick. */ const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; const MAX_CACHE_ENTRIES = 100; const SWEEP_SAMPLE_RATE = 0.01; const defaultDeps = { homeDir: () => os.homedir(), now: () => Date.now(), random: () => Math.random(), }; /** * Resolve the session-scoped cache file used for context window fallback. * Uses a sha256 of the transcript path so that concurrent Claude Code * sessions never share or overwrite each other's cached snapshots. */ function getCachePath(homeDir, transcriptPath) { const hash = createHash("sha256") .update(path.resolve(transcriptPath)) .digest("hex"); return path.join(getHudPluginDir(homeDir), CACHE_DIRNAME, `${hash}.json`); } /** * Resolve the cache directory that holds all session-scoped snapshots. */ function getCacheDir(homeDir) { return path.join(getHudPluginDir(homeDir), CACHE_DIRNAME); } function ensurePrivateDir(dir) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); try { fs.chmodSync(dir, 0o700); } catch { // Best-effort: some filesystems do not support POSIX modes. } } /** * Read the last known good context snapshot from disk. * Returns null when the cache is missing, malformed, or invalid. */ function readCache(homeDir, transcriptPath) { try { const cachePath = getCachePath(homeDir, transcriptPath); if (!fs.existsSync(cachePath)) return null; const content = fs.readFileSync(cachePath, "utf8"); const parsed = JSON.parse(content); if (typeof parsed.used_percentage !== "number" || !Number.isFinite(parsed.used_percentage)) { return null; } return parsed; } catch (err) { debug('Failed to read context cache:', err instanceof Error ? err.message : err); return null; } } /** * Decide whether the current write can be skipped because the cached snapshot * for this session was refreshed recently enough. */ function shouldSkipWrite(cachePath, now) { try { const stat = fs.statSync(cachePath); return now - stat.mtimeMs < WRITE_TTL_MS; } catch (err) { debug('Cache stat check failed (will write):', err instanceof Error ? err.message : err); return false; } } /** * Persist a known-good context snapshot for future fallback use. * Any write failure is intentionally ignored to keep rendering non-blocking. */ function writeCache(homeDir, transcriptPath, contextWindow, now, sessionName) { try { const cachePath = getCachePath(homeDir, transcriptPath); if (shouldSkipWrite(cachePath, now)) { return; } const cacheDir = path.dirname(cachePath); ensurePrivateDir(cacheDir); const payload = { used_percentage: contextWindow.used_percentage ?? 0, remaining_percentage: contextWindow.remaining_percentage ?? null, current_usage: contextWindow.current_usage ?? null, context_window_size: contextWindow.context_window_size ?? null, saved_at: now, session_name: sessionName ?? null, }; fs.writeFileSync(cachePath, JSON.stringify(payload), { encoding: "utf8", mode: 0o600, }); try { fs.chmodSync(cachePath, 0o600); } catch { // Best-effort: some filesystems do not support POSIX modes. } const timestampSeconds = now / 1000; fs.utimesSync(cachePath, timestampSeconds, timestampSeconds); } catch (err) { debug('Failed to write context cache:', err instanceof Error ? err.message : err); } } /** * Remove stale cache entries and enforce a hard cap on total file count. * Safe to run opportunistically; every per-file failure is swallowed. */ function sweepCacheDir(cacheDir, now) { try { if (!fs.existsSync(cacheDir)) return; const entries = fs.readdirSync(cacheDir, { withFileTypes: true }); const survivors = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".json")) continue; const fullPath = path.join(cacheDir, entry.name); try { const stat = fs.statSync(fullPath); if (now - stat.mtimeMs > MAX_CACHE_AGE_MS) { fs.unlinkSync(fullPath); continue; } survivors.push({ fullPath, mtimeMs: stat.mtimeMs }); } catch (err) { debug('Sweep: failed to process %s:', fullPath, err instanceof Error ? err.message : err); } } if (survivors.length > MAX_CACHE_ENTRIES) { survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); const toDelete = survivors.length - MAX_CACHE_ENTRIES; for (let i = 0; i < toDelete; i += 1) { try { fs.unlinkSync(survivors[i].fullPath); } catch (err) { debug('Sweep: failed to unlink %s:', survivors[i].fullPath, err instanceof Error ? err.message : err); } } } } catch (err) { debug('Cache sweep failed:', err instanceof Error ? err.message : err); } } /** * Check whether all tracked token counters in current_usage are zero. */ function isAllUsageZero(usage) { if (!usage) { return true; } return ((usage.input_tokens ?? 0) === 0 && (usage.output_tokens ?? 0) === 0 && (usage.cache_creation_input_tokens ?? 0) === 0 && (usage.cache_read_input_tokens ?? 0) === 0); } /** * Returns true when context window data looks like a Claude Code reporting * glitch rather than a genuine zero-usage state. * * We treat a zero-percent frame as suspicious when `current_usage` is empty. * Fresh sessions are protected by a cache miss; post-compact resets are * protected by the compact-boundary guard in applyContextWindowFallback. */ function isSuspiciousZero(contextWindow) { const usedPercentage = contextWindow.used_percentage ?? 0; if (usedPercentage !== 0) { return false; } if (!isAllUsageZero(contextWindow.current_usage)) { return false; } return true; } /** * Determine whether the current frame contains a usable context snapshot. */ function hasGoodContext(contextWindow) { return ((contextWindow.context_window_size ?? 0) > 0 && typeof contextWindow.used_percentage === "number" && contextWindow.used_percentage > 0); } /** * Merge cached context fields into the current frame. * Prefer the frame's context_window_size when already present. */ function applyCachedContext(contextWindow, cache) { contextWindow.used_percentage = cache.used_percentage; contextWindow.remaining_percentage = cache.remaining_percentage ?? null; contextWindow.current_usage = cache.current_usage ?? null; contextWindow.context_window_size = contextWindow.context_window_size ?? cache.context_window_size ?? undefined; } /** * Apply context-window fallback in-place: * - For suspicious zero frames, try restoring from the session-scoped cache. * - For healthy frames, refresh the cache snapshot for this session * (subject to TTL + value-change throttling to avoid hot-path writes). * * When `compactHint.lastCompactBoundaryAt` is newer than the cached snapshot's * `saved_at`, the zero frame is treated as a legitimate post-/compact reset and * the stale pre-compact snapshot is NOT restored. If `lastCompactPostTokens` * is provided, it is used to synthesize an accurate transition-window percent. * * No-op when stdin has no transcript_path, since without a stable session key * we cannot safely isolate cache entries across concurrent Claude Code sessions. */ export function applyContextWindowFallback(stdin, overrides = {}, sessionName, compactHint) { const contextWindow = stdin.context_window; if (!contextWindow) { return; } const transcriptPath = stdin.transcript_path?.trim(); if (!transcriptPath) { return; } const deps = { ...defaultDeps, ...overrides }; const homeDir = deps.homeDir(); const now = deps.now(); if (isSuspiciousZero(contextWindow)) { const cached = readCache(homeDir, transcriptPath); const boundaryMs = compactHint?.lastCompactBoundaryAt?.getTime(); const isPostCompactReset = typeof boundaryMs === "number" && Number.isFinite(boundaryMs) && (!cached?.saved_at || boundaryMs > cached.saved_at); if (isPostCompactReset) { // Legitimate /compact reset: keep the zero frame instead of restoring a // stale pre-compact snapshot. Surface the compactMetadata.postTokens // value (when available) so the bar shows the real post-compact // percent during the transition before the next assistant response. const postTokens = compactHint?.lastCompactPostTokens; const size = contextWindow.context_window_size ?? 0; if (typeof postTokens === "number" && postTokens > 0 && size > 0) { const pct = Math.min(100, Math.max(0, Math.round((postTokens / size) * 100))); contextWindow.used_percentage = pct; contextWindow.remaining_percentage = 100 - pct; } } else if (cached) { applyCachedContext(contextWindow, cached); } } if (hasGoodContext(contextWindow)) { writeCache(homeDir, transcriptPath, contextWindow, now, sessionName); if (deps.random() < SWEEP_SAMPLE_RATE) { sweepCacheDir(getCacheDir(homeDir), now); } } } /** * Test-only entrypoint for deterministically exercising the sweep logic. */ export function _sweepCacheForTests(homeDir, now) { sweepCacheDir(getCacheDir(homeDir), now); } //# sourceMappingURL=context-cache.js.map