codeburn/src/codex-cache.ts
Resham Joshi 80e3de3bab
fix(codex): attribute MCP calls emitted as event_msg/mcp_tool_call_end (#513)
Recent Codex sessions report completed MCP tool calls as an event_msg with
type mcp_tool_call_end instead of a function_call response_item. The parser
only built its tool list from function_call and patch_apply_end events, so
these MCP calls were never attributed: token usage was still counted, but the
MCP tool itself was missing from recent lookback windows (today/week/month)
while older history still showed it.

Add a handler that reads invocation.server and invocation.tool from the
event and rebuilds the canonical mcp__<server>__<tool> name the classifier
recognizes. Bump the Codex result cache to v4 so sessions cached under v3
re-parse and pick up the previously dropped MCP calls.

Validated on real local data: 5 mcp_tool_call_end events that were dropped
before are now attributed as mcp__node_repl__js, with no change to the total
call count.

Fixes #478
2026-06-18 22:32:40 +02:00

145 lines
3.8 KiB
TypeScript

import { readFile, mkdir, stat, open, rename, unlink } from 'fs/promises'
import { existsSync } from 'fs'
import { randomBytes } from 'crypto'
import { join } from 'path'
import { homedir } from 'os'
import type { ParsedProviderCall } from './providers/types.js'
// v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478).
// Recent Codex sessions cached under v3 dropped these, so force a re-parse.
const CODEX_CACHE_VERSION = 4
const CACHE_FILE = 'codex-results.json'
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
type FileEntry = {
mtimeMs: number
sizeBytes: number
project: string
calls: ParsedProviderCall[]
}
type ResultCache = {
version: number
files: Record<string, FileEntry>
}
function getCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}
function getCachePath(): string {
return join(getCacheDir(), CACHE_FILE)
}
let memCache: ResultCache | null = null
async function loadCache(): Promise<ResultCache> {
if (memCache) return memCache
try {
const raw = await readFile(getCachePath(), 'utf-8')
const cache = JSON.parse(raw) as ResultCache
if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') {
memCache = cache
return cache
}
} catch {}
memCache = { version: CODEX_CACHE_VERSION, files: {} }
return memCache
}
function getEntry(cache: ResultCache, filePath: string, fp: FileFingerprint): FileEntry | null {
if (!Object.hasOwn(cache.files, filePath)) return null
const entry = cache.files[filePath]
if (entry && entry.mtimeMs === fp.mtimeMs && entry.sizeBytes === fp.sizeBytes) {
return entry
}
return null
}
export async function readCachedCodexResults(
filePath: string,
): Promise<ParsedProviderCall[] | null> {
try {
const s = await stat(filePath)
const cache = await loadCache()
const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size })
return entry?.calls ?? null
} catch {}
return null
}
export async function getCachedCodexProject(
filePath: string,
): Promise<string | null> {
try {
const s = await stat(filePath)
const cache = await loadCache()
const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size })
return entry?.project ?? null
} catch {}
return null
}
export async function fingerprintFile(
filePath: string,
): Promise<FileFingerprint | null> {
try {
const s = await stat(filePath)
return { mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
return null
}
}
export async function writeCachedCodexResults(
filePath: string,
project: string,
calls: ParsedProviderCall[],
fingerprint: FileFingerprint,
): Promise<void> {
try {
const cache = await loadCache()
cache.files[filePath] = {
mtimeMs: fingerprint.mtimeMs,
sizeBytes: fingerprint.sizeBytes,
project,
calls,
}
} catch {}
}
export async function flushCodexCache(): Promise<void> {
if (!memCache) return
try {
// Evict entries for files that no longer exist on disk
const paths = Object.keys(memCache.files)
for (const p of paths) {
try {
await stat(p)
} catch {
delete memCache.files[p]
}
}
const dir = getCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
const payload = JSON.stringify(memCache)
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(payload, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(tempPath, finalPath)
} catch (err) {
try { await unlink(tempPath) } catch {}
throw err
}
} catch {}
}