import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' import { randomBytes } from 'crypto' import type { ParsedProviderCall } from './providers/types.js' // Bumped to 3 for the workspace-aware breakdown change: the cursor parser // now derives `sessionId` from the bubble row key (the real composer id) // rather than the empty `conversationId` JSON field, and the workspace // router relies on those composer ids to bucket calls per project. // Version 2 caches contain `sessionId: 'unknown'` for every call and would // route everything to the orphan project, so we invalidate them. // Version 5: parseAgentKv was removed (it double-counted against bubbles); // real context tokens from composerData.promptTokenBreakdown now drive // input, and agentKv is used only for the tools/bash breakdown. Cached v4 // results contain stale agentKv calls and lack the real token figures. // Version 6: conversation input moved to composer-anchored records // (cursor:composer-input:) with per-conversation source selection, the // agent stream regained tool/system context and stream-only sessions, and // tool names are canonicalized. v5 results mix crediting regimes. const CURSOR_CACHE_VERSION = 6 type ResultCache = { version?: number dbMtimeMs: number dbSizeBytes: number lookbackFloor: string calls: ParsedProviderCall[] } const CACHE_FILE = 'cursor-results.json' function getCacheDir(): string { return join(homedir(), '.cache', 'codeburn') } function getCachePath(): string { return join(getCacheDir(), CACHE_FILE) } async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> { try { const s = await stat(dbPath) return { mtimeMs: s.mtimeMs, size: s.size } } catch { return null } } export async function readCachedResults( dbPath: string, requestedFloor: string, ): Promise { try { const fp = await getDbFingerprint(dbPath) if (!fp) return null const raw = await readFile(getCachePath(), 'utf-8') const cache = JSON.parse(raw) as ResultCache if ( cache.version === CURSOR_CACHE_VERSION && cache.dbMtimeMs === fp.mtimeMs && cache.dbSizeBytes === fp.size && typeof cache.lookbackFloor === 'string' && cache.lookbackFloor <= requestedFloor ) { return cache.calls } return null } catch { return null } } export async function writeCachedResults( dbPath: string, calls: ParsedProviderCall[], lookbackFloor: string, ): Promise { // Diagnostic contexts (codeburn doctor) sample-parse providers under a // strictly read-only promise; this is the one parse path that writes to // disk before its first yield, so it honors the suppression flag. if (process.env['CODEBURN_SUPPRESS_CACHE_WRITES']) return const fp = await getDbFingerprint(dbPath) if (!fp) return const dir = getCacheDir() await mkdir(dir, { recursive: true }).catch(() => {}) const cache: ResultCache = { version: CURSOR_CACHE_VERSION, dbMtimeMs: fp.mtimeMs, dbSizeBytes: fp.size, lookbackFloor, calls, } // Atomic write: stage to a randomized temp file in the same directory, // then rename onto the final path. rename() is atomic on POSIX, so a // crash mid-write never leaves a half-written cache, and concurrent // CLI invocations using their own random temp names cannot interleave // bytes in the destination file (they only race on the final rename, // last-writer-wins, both with valid content). const target = getCachePath() const tempPath = `${target}.${randomBytes(8).toString('hex')}.tmp` try { await writeFile(tempPath, JSON.stringify(cache), 'utf-8') await rename(tempPath, target) } catch { await unlink(tempPath).catch(() => {}) } }