mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-27 01:45:00 +00:00
* Fix Antigravity provider detection, Codex fork double-counting, and tab ordering
Antigravity:
- Handle ephemeral port (--https_server_port 0) via lsof fallback
- Force reparse when cached turns are 0 (server may have been unavailable)
- Persist precomputed costUSD in session cache for correct pricing
- Map gemini-pro-agent to gemini-3.1-pro pricing
- Add display names for gemini-pro-agent and gemini-3.5-flash-low
Codex:
- Detect forked sessions via forked_from_id in session_meta
- Skip replayed parent events within 5s of fork creation time
- Use parent session ID in dedup key so parent+fork don't double-count
Menubar:
- Sort provider tabs by cost descending instead of enum declaration order
* Add tooling breakdowns to CLI dashboard and menubar
Surface skills, subagents, tools, and MCP server usage in both the
text dashboard and menubar app. Parse subagent types from Agent/Task
tool calls, aggregate with skills into a merged "Skills & Agents"
panel. Remove Top Sessions panel from CLI dashboard.
* Fix Codex always reporting 100% one-shot rate
Codex parser never set turnId or toolSequence, so retry detection
could not see Edit→Bash→Edit patterns across API calls. Now each
tool event builds a toolSequence step, and user messages generate
a new turnId to group calls into logical turns. Bump Codex cache
version to force re-parse of existing sessions.
* File-aware retry detection with typed ToolCall
Replace name-only Edit→Bash→Edit retry detection with file-aware
tracking. A retry now requires the SAME file to be re-edited after
a bash step, not just any edit after any bash.
- Add ToolCall type ({ tool, file?, command? }) replacing string[][]
for toolSequence across all types, providers, and cache
- Generate per-tool toolSequence in Claude parser with file paths
from Edit/Write tool inputs
- Extract file_path for Edit/Write tools in all three parser paths
(string, buffer, compact)
- Fix apiCallToCachedCall missing toolSequence (Claude data was never
cached with tool sequence info)
- Update Codex, Goose, Kiro parsers for typed ToolCall
- Bump session cache version to 3, codex cache version to 3
* Extract file paths from Codex patch_apply_end and function_call args
Codex stores file paths in patch_apply_end.changes keys and function
call arguments as a JSON string. Parse both to populate ToolCall.file,
enabling file-aware retry detection for Codex sessions.
* Fix retry detection fallback for file-less providers, update docs
- Use __no_file__ sentinel in countRetries so providers without file
paths (Kiro, Gemini, Vibe) fall back to name-based retry detection
- Reset pendingToolSequence on Codex dedup skip to prevent leak
- Update classifier tests to ToolCall[][] format
- Document file-aware one-shot rate in README
- Add unreleased changelog entries for tooling breakdowns and fixes
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { createHash, randomBytes } from 'crypto'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
|
|
import type { ToolCall } from './types.js'
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
|
|
export type CachedUsage = {
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheCreationInputTokens: number
|
|
cacheReadInputTokens: number
|
|
cachedInputTokens: number
|
|
reasoningTokens: number
|
|
webSearchRequests: number
|
|
cacheCreationOneHourTokens: number
|
|
}
|
|
|
|
export type CachedCall = {
|
|
provider: string
|
|
model: string
|
|
usage: CachedUsage
|
|
costUSD?: number
|
|
speed: 'standard' | 'fast'
|
|
timestamp: string
|
|
tools: string[]
|
|
bashCommands: string[]
|
|
skills: string[]
|
|
subagentTypes: string[]
|
|
deduplicationKey: string
|
|
project?: string
|
|
projectPath?: string
|
|
toolSequence?: ToolCall[][]
|
|
}
|
|
|
|
export type CachedTurn = {
|
|
timestamp: string
|
|
sessionId: string
|
|
userMessage: string
|
|
calls: CachedCall[]
|
|
}
|
|
|
|
export type FileFingerprint = {
|
|
dev: number
|
|
ino: number
|
|
mtimeMs: number
|
|
sizeBytes: number
|
|
}
|
|
|
|
export type CachedFile = {
|
|
fingerprint: FileFingerprint
|
|
lastCompleteLineOffset?: number
|
|
canonicalCwd?: string
|
|
mcpInventory: string[]
|
|
turns: CachedTurn[]
|
|
}
|
|
|
|
export type ProviderSection = {
|
|
envFingerprint: string
|
|
files: Record<string, CachedFile>
|
|
}
|
|
|
|
export type SessionCache = {
|
|
version: number
|
|
providers: Record<string, ProviderSection>
|
|
}
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
|
|
export const CACHE_VERSION = 3
|
|
|
|
const CACHE_FILE = 'session-cache.json'
|
|
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
|
|
|
|
const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
|
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'],
|
|
codex: ['CODEX_HOME'],
|
|
droid: ['FACTORY_DIR'],
|
|
cursor: ['XDG_DATA_HOME'],
|
|
'cursor-agent': ['XDG_DATA_HOME'],
|
|
opencode: ['XDG_DATA_HOME'],
|
|
goose: ['XDG_DATA_HOME'],
|
|
crush: ['XDG_DATA_HOME'],
|
|
warp: ['WARP_DB_PATH'],
|
|
antigravity: ['CODEBURN_CACHE_DIR'],
|
|
qwen: ['QWEN_DATA_DIR'],
|
|
'ibm-bob': ['XDG_CONFIG_HOME'],
|
|
}
|
|
|
|
// ── Cache Dir ──────────────────────────────────────────────────────────
|
|
|
|
function getCacheDir(): string {
|
|
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
|
|
}
|
|
|
|
function getCachePath(): string {
|
|
return join(getCacheDir(), CACHE_FILE)
|
|
}
|
|
|
|
// ── Env Fingerprint ────────────────────────────────────────────────────
|
|
|
|
export function computeEnvFingerprint(provider: string): string {
|
|
const vars = PROVIDER_ENV_VARS[provider] ?? []
|
|
const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`)
|
|
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
|
|
}
|
|
|
|
// ── Load / Save ────────────────────────────────────────────────────────
|
|
|
|
export function emptyCache(): SessionCache {
|
|
return { version: CACHE_VERSION, providers: {} }
|
|
}
|
|
|
|
function isNum(v: unknown): v is number {
|
|
return typeof v === 'number' && Number.isFinite(v)
|
|
}
|
|
|
|
function isStringArray(v: unknown): v is string[] {
|
|
return Array.isArray(v) && v.every(e => typeof e === 'string')
|
|
}
|
|
|
|
function isOptionalString(v: unknown): boolean {
|
|
return v === undefined || typeof v === 'string'
|
|
}
|
|
|
|
function isOptionalNum(v: unknown): boolean {
|
|
return v === undefined || isNum(v)
|
|
}
|
|
|
|
function isToolCall(v: unknown): boolean {
|
|
if (!v || typeof v !== 'object') return false
|
|
const o = v as Record<string, unknown>
|
|
return typeof o['tool'] === 'string'
|
|
&& isOptionalString(o['file'])
|
|
&& isOptionalString(o['command'])
|
|
}
|
|
|
|
function isToolCallArray(v: unknown): boolean {
|
|
return Array.isArray(v) && (v as unknown[]).every(isToolCall)
|
|
}
|
|
|
|
function validateFingerprint(fp: unknown): fp is FileFingerprint {
|
|
if (!fp || typeof fp !== 'object') return false
|
|
const f = fp as Record<string, unknown>
|
|
return isNum(f['dev']) && isNum(f['ino']) && isNum(f['mtimeMs']) && isNum(f['sizeBytes'])
|
|
}
|
|
|
|
function validateUsage(u: unknown): u is CachedUsage {
|
|
if (!u || typeof u !== 'object') return false
|
|
const o = u as Record<string, unknown>
|
|
return isNum(o['inputTokens']) && isNum(o['outputTokens'])
|
|
&& isNum(o['cacheCreationInputTokens']) && isNum(o['cacheReadInputTokens'])
|
|
&& isNum(o['cachedInputTokens']) && isNum(o['reasoningTokens'])
|
|
&& isNum(o['webSearchRequests']) && isNum(o['cacheCreationOneHourTokens'])
|
|
}
|
|
|
|
function validateCall(c: unknown): c is CachedCall {
|
|
if (!c || typeof c !== 'object') return false
|
|
const o = c as Record<string, unknown>
|
|
return typeof o['provider'] === 'string'
|
|
&& typeof o['model'] === 'string'
|
|
&& typeof o['deduplicationKey'] === 'string'
|
|
&& typeof o['timestamp'] === 'string'
|
|
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
|
|
&& isOptionalNum(o['costUSD'])
|
|
&& isStringArray(o['tools'])
|
|
&& isStringArray(o['bashCommands'])
|
|
&& isStringArray(o['skills'])
|
|
&& isOptionalString(o['project'])
|
|
&& isOptionalString(o['projectPath'])
|
|
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
|
|
&& validateUsage(o['usage'])
|
|
}
|
|
|
|
function validateTurn(t: unknown): t is CachedTurn {
|
|
if (!t || typeof t !== 'object') return false
|
|
const o = t as Record<string, unknown>
|
|
return typeof o['timestamp'] === 'string'
|
|
&& typeof o['sessionId'] === 'string'
|
|
&& typeof o['userMessage'] === 'string'
|
|
&& Array.isArray(o['calls'])
|
|
&& (o['calls'] as unknown[]).every(validateCall)
|
|
}
|
|
|
|
function validateCachedFile(f: unknown): f is CachedFile {
|
|
if (!f || typeof f !== 'object') return false
|
|
const o = f as Record<string, unknown>
|
|
return validateFingerprint(o['fingerprint'])
|
|
&& isOptionalNum(o['lastCompleteLineOffset'])
|
|
&& isOptionalString(o['canonicalCwd'])
|
|
&& isStringArray(o['mcpInventory'])
|
|
&& Array.isArray(o['turns'])
|
|
&& (o['turns'] as unknown[]).every(validateTurn)
|
|
}
|
|
|
|
function validateProviderSection(s: unknown): s is ProviderSection {
|
|
if (!s || typeof s !== 'object') return false
|
|
const o = s as Record<string, unknown>
|
|
if (typeof o['envFingerprint'] !== 'string') return false
|
|
if (!o['files'] || typeof o['files'] !== 'object' || Array.isArray(o['files'])) return false
|
|
return Object.values(o['files'] as Record<string, unknown>).every(validateCachedFile)
|
|
}
|
|
|
|
function validateCache(raw: unknown): raw is SessionCache {
|
|
if (!raw || typeof raw !== 'object') return false
|
|
const o = raw as Record<string, unknown>
|
|
if (o['version'] !== CACHE_VERSION) return false
|
|
if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false
|
|
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
|
|
}
|
|
|
|
export async function loadCache(): Promise<SessionCache> {
|
|
try {
|
|
const raw = await readFile(getCachePath(), 'utf-8')
|
|
const parsed = JSON.parse(raw)
|
|
if (!validateCache(parsed)) return emptyCache()
|
|
return parsed
|
|
} catch {
|
|
return emptyCache()
|
|
}
|
|
}
|
|
|
|
export async function saveCache(cache: SessionCache): Promise<void> {
|
|
const dir = getCacheDir()
|
|
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
|
|
|
|
const finalPath = getCachePath()
|
|
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
|
|
delete (cache as { _dirty?: boolean })._dirty
|
|
const payload = JSON.stringify(cache)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── File Fingerprinting ────────────────────────────────────────────────
|
|
|
|
export async function fingerprintFile(filePath: string): Promise<FileFingerprint | null> {
|
|
try {
|
|
const s = await stat(filePath)
|
|
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
|
} catch {
|
|
// Providers encode extra context into source paths using virtual suffixes:
|
|
// - Cursor: `<dbPath>#cursor-ws=<workspace>` (workspace-aware routing)
|
|
// - OpenCode: `<dbPath>:<sessionId>` (session scoping)
|
|
// These compound paths don't exist on disk; strip the suffix to stat the
|
|
// underlying file. Try `#` first (rare in real paths), then `:` (must use
|
|
// lastIndexOf to tolerate Windows drive letters like C:\...).
|
|
const hashIdx = filePath.indexOf('#')
|
|
if (hashIdx > 0) {
|
|
try {
|
|
const s = await stat(filePath.slice(0, hashIdx))
|
|
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
|
} catch {
|
|
// fall through to colon check
|
|
}
|
|
}
|
|
const colonIdx = filePath.lastIndexOf(':')
|
|
if (colonIdx > 0) {
|
|
try {
|
|
const s = await stat(filePath.slice(0, colonIdx))
|
|
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ── Reconciliation ─────────────────────────────────────────────────────
|
|
|
|
export type ReconcileAction =
|
|
| { action: 'unchanged' }
|
|
| { action: 'appended'; readFromOffset: number }
|
|
| { action: 'modified' }
|
|
| { action: 'new' }
|
|
|
|
export function reconcileFile(
|
|
current: FileFingerprint,
|
|
cached: CachedFile | undefined,
|
|
): ReconcileAction {
|
|
if (!cached) return { action: 'new' }
|
|
|
|
const fp = cached.fingerprint
|
|
|
|
if (
|
|
fp.dev === current.dev &&
|
|
fp.ino === current.ino &&
|
|
fp.mtimeMs === current.mtimeMs &&
|
|
fp.sizeBytes === current.sizeBytes
|
|
) {
|
|
return { action: 'unchanged' }
|
|
}
|
|
|
|
if (
|
|
cached.lastCompleteLineOffset !== undefined &&
|
|
fp.dev === current.dev &&
|
|
fp.ino === current.ino &&
|
|
current.sizeBytes > fp.sizeBytes
|
|
) {
|
|
return { action: 'appended', readFromOffset: cached.lastCompleteLineOffset }
|
|
}
|
|
|
|
return { action: 'modified' }
|
|
}
|
|
|
|
// ── Dedup Merge ────────────────────────────────────────────────────────
|
|
// When appending incremental data, streaming Claude messages can re-emit
|
|
// the same dedup key with updated usage. Merge by key: keep the earliest
|
|
// timestamp, take incoming usage/tools/bashCommands/skills (latest wins).
|
|
|
|
export function mergeCallByDedupKey(
|
|
existing: CachedCall,
|
|
incoming: CachedCall,
|
|
): CachedCall {
|
|
return {
|
|
...incoming,
|
|
timestamp: existing.timestamp < incoming.timestamp
|
|
? existing.timestamp
|
|
: incoming.timestamp,
|
|
}
|
|
}
|
|
|
|
// ── Temp Cleanup ───────────────────────────────────────────────────────
|
|
|
|
export async function cleanupOrphanedTempFiles(): Promise<void> {
|
|
const dir = getCacheDir()
|
|
if (!existsSync(dir)) return
|
|
|
|
try {
|
|
const entries = await readdir(dir)
|
|
const now = Date.now()
|
|
|
|
const prefix = 'session-cache.json.'
|
|
for (const entry of entries) {
|
|
if (!entry.startsWith(prefix) || !entry.endsWith('.tmp')) continue
|
|
try {
|
|
const fullPath = join(dir, entry)
|
|
const s = await stat(fullPath)
|
|
if (now - s.mtimeMs > TEMP_FILE_MAX_AGE_MS) {
|
|
await unlink(fullPath)
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
|