mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
feat(cursor): add tools and bash-command breakdown from agentKv
Cursor logs the agent's tool calls (Read, Grep, Glob, Shell, ...) in agentKv
blobs. Join them to conversations via the turn requestId (carried on the bubble's
$.requestId and inherited positionally by the turn's agentKv rows) and attach each
conversation's tool list and Shell commands to the call that carries its input.
Measured on a real DB: Cursor now reports tools {Read, Grep, Glob, Shell,
SemanticSearch} and the executed shell commands, which were previously empty.
Removes the now-superseded parseAgentKv content-char estimate.
This commit is contained in:
parent
03cae01e31
commit
867f709b29
1 changed files with 82 additions and 147 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { existsSync, statSync, readdirSync, readFileSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
|
|
@ -66,17 +66,6 @@ type AgentKvRow = {
|
|||
content_length: number
|
||||
}
|
||||
|
||||
type AgentKvContent = {
|
||||
type?: string
|
||||
text?: string
|
||||
providerOptions?: {
|
||||
cursor?: {
|
||||
modelName?: string
|
||||
requestId?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
function getCursorDbPath(): string {
|
||||
|
|
@ -497,11 +486,73 @@ function loadComposerInputTokens(db: SqliteDatabase): Map<string, number> {
|
|||
return map
|
||||
}
|
||||
|
||||
type AgentTools = { tools: string[]; bash: string[] }
|
||||
|
||||
// Cursor logs the agent's tool calls (Read, Grep, Shell, ...) in agentKv blobs
|
||||
// keyed by requestId. Bubbles carry the same requestId plus the composerId, so
|
||||
// joining the two attributes each conversation's tools and Shell commands.
|
||||
const BUBBLE_REQUESTID_QUERY = `
|
||||
SELECT key as bubble_key, json_extract(value, '$.requestId') as request_id
|
||||
FROM cursorDiskKV
|
||||
WHERE key LIKE 'bubbleId:%' AND json_extract(value, '$.requestId') IS NOT NULL
|
||||
`
|
||||
|
||||
function loadAgentToolsByComposer(db: SqliteDatabase): Map<string, AgentTools> {
|
||||
const byComposer = new Map<string, AgentTools>()
|
||||
|
||||
const requestToComposer = new Map<string, string>()
|
||||
try {
|
||||
const rows = db.query<{ bubble_key: string; request_id: string | null }>(BUBBLE_REQUESTID_QUERY)
|
||||
for (const r of rows) {
|
||||
const composer = parseComposerIdFromKey(r.bubble_key)
|
||||
if (composer && r.request_id) requestToComposer.set(r.request_id, composer)
|
||||
}
|
||||
} catch {
|
||||
return byComposer
|
||||
}
|
||||
|
||||
let rows: AgentKvRow[]
|
||||
try {
|
||||
rows = db.query<AgentKvRow>(AGENTKV_QUERY)
|
||||
} catch {
|
||||
return byComposer
|
||||
}
|
||||
|
||||
// Only the turn-opening (user) agentKv row carries the requestId; the
|
||||
// assistant rows that follow inherit it, so track it positionally.
|
||||
let currentRequestId: string | null = null
|
||||
for (const row of rows) {
|
||||
if (row.request_id) currentRequestId = row.request_id
|
||||
if (row.role !== 'assistant' || !row.content || !currentRequestId) continue
|
||||
const composer = requestToComposer.get(currentRequestId)
|
||||
if (!composer) continue
|
||||
let content: unknown
|
||||
try {
|
||||
content = JSON.parse(blobToText(row.content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(content)) continue
|
||||
const bucket = byComposer.get(composer) ?? { tools: [], bash: [] }
|
||||
for (const block of content as Array<{ type?: string; toolName?: string; args?: { command?: string } }>) {
|
||||
if (!block || block.type !== 'tool-call' || !block.toolName) continue
|
||||
bucket.tools.push(block.toolName)
|
||||
if (block.toolName === 'Shell') {
|
||||
const command = block.args?.command?.trim()
|
||||
if (command) bucket.bash.push(command)
|
||||
}
|
||||
}
|
||||
byComposer.set(composer, bucket)
|
||||
}
|
||||
return byComposer
|
||||
}
|
||||
|
||||
function parseBubbles(
|
||||
db: SqliteDatabase,
|
||||
seenKeys: Set<string>,
|
||||
timeFloor: string,
|
||||
composerInput: Map<string, number>,
|
||||
agentTools: Map<string, AgentTools>,
|
||||
): { calls: ParsedProviderCall[] } {
|
||||
const results: ParsedProviderCall[] = []
|
||||
let skipped = 0
|
||||
|
|
@ -569,6 +620,9 @@ function parseBubbles(
|
|||
|
||||
let inputTokens = row.input_tokens ?? 0
|
||||
let outputTokens = row.output_tokens ?? 0
|
||||
// The conversation's tools/bash attach to the single call that carries its
|
||||
// real input (its first turn), so they are counted exactly once.
|
||||
let creditedHere = false
|
||||
|
||||
// Current Cursor leaves tokenCount at {0,0}. Use the conversation's real
|
||||
// context size (promptTokenBreakdown) for input, credited once per
|
||||
|
|
@ -579,8 +633,13 @@ function parseBubbles(
|
|||
if (row.bubble_type === 1) {
|
||||
const real = composerInput.get(conversationId)
|
||||
if (real != null) {
|
||||
inputTokens = creditedComposers.has(conversationId) ? 0 : real
|
||||
creditedComposers.add(conversationId)
|
||||
if (creditedComposers.has(conversationId)) {
|
||||
inputTokens = 0
|
||||
} else {
|
||||
inputTokens = real
|
||||
creditedComposers.add(conversationId)
|
||||
creditedHere = true
|
||||
}
|
||||
} else {
|
||||
inputTokens = Math.ceil(textLen / CHARS_PER_TOKEN)
|
||||
}
|
||||
|
|
@ -612,7 +671,12 @@ function parseBubbles(
|
|||
const languages = extractLanguages(blobToText(row.code_blocks))
|
||||
const hasCode = languages.length > 0
|
||||
|
||||
const cursorTools: string[] = hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : []
|
||||
const agentTurn = creditedHere ? agentTools.get(conversationId) : undefined
|
||||
const cursorTools: string[] = [
|
||||
...(hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : []),
|
||||
...(agentTurn?.tools ?? []),
|
||||
]
|
||||
const bashCommands = agentTurn?.bash ?? []
|
||||
|
||||
results.push({
|
||||
provider: 'cursor',
|
||||
|
|
@ -626,7 +690,7 @@ function parseBubbles(
|
|||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: cursorTools,
|
||||
bashCommands: [],
|
||||
bashCommands,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
|
|
@ -645,136 +709,6 @@ function parseBubbles(
|
|||
return { calls: results }
|
||||
}
|
||||
|
||||
function extractModelFromContent(content: AgentKvContent[]): string | null {
|
||||
for (const c of content) {
|
||||
if (c.providerOptions?.cursor?.modelName) {
|
||||
return c.providerOptions.cursor.modelName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractTextLength(content: AgentKvContent[]): number {
|
||||
let total = 0
|
||||
for (const c of content) {
|
||||
if (c.text) total += c.text.length
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function parseAgentKv(db: SqliteDatabase, seenKeys: Set<string>, dbPath: string): { calls: ParsedProviderCall[] } {
|
||||
const results: ParsedProviderCall[] = []
|
||||
|
||||
// Cursor's agentKv schema does not record per-message timestamps. Use the
|
||||
// SQLite file's mtime as a bounded "last write" timestamp for all calls;
|
||||
// it's at least honest (no future time, no always-now). Users running
|
||||
// codeburn against an idle Cursor install will see agentKv calls land at
|
||||
// the actual last activity time rather than today's date.
|
||||
let agentKvTimestamp: string
|
||||
try {
|
||||
agentKvTimestamp = new Date(statSync(dbPath).mtimeMs).toISOString()
|
||||
} catch {
|
||||
agentKvTimestamp = new Date().toISOString()
|
||||
}
|
||||
|
||||
let rows: AgentKvRow[]
|
||||
try {
|
||||
rows = db.query<AgentKvRow>(AGENTKV_QUERY)
|
||||
} catch {
|
||||
return { calls: results }
|
||||
}
|
||||
|
||||
const sessions: Map<string, { inputChars: number; outputChars: number; model: string | null; userText: string }> = new Map()
|
||||
let currentRequestId = 'unknown'
|
||||
let turnIndex = 0
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.role || !row.content) continue
|
||||
const contentText = blobToText(row.content)
|
||||
|
||||
let content: AgentKvContent[]
|
||||
let plainTextLength = 0
|
||||
try {
|
||||
const parsed = JSON.parse(contentText)
|
||||
if (Array.isArray(parsed)) {
|
||||
content = parsed
|
||||
} else {
|
||||
content = []
|
||||
plainTextLength = contentText.length
|
||||
}
|
||||
} catch {
|
||||
content = []
|
||||
plainTextLength = contentText.length
|
||||
}
|
||||
|
||||
const requestId = row.request_id ?? currentRequestId
|
||||
if (requestId !== currentRequestId) {
|
||||
currentRequestId = requestId
|
||||
turnIndex = 0
|
||||
}
|
||||
|
||||
const textLength = plainTextLength || extractTextLength(content)
|
||||
const model = extractModelFromContent(content)
|
||||
|
||||
if (row.role === 'user') {
|
||||
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' }
|
||||
existing.inputChars += textLength
|
||||
if (!existing.userText) {
|
||||
const text = content[0]?.text ?? contentText
|
||||
const queryMatch = text.match(/<user_query>([\s\S]*?)<\/user_query>/)
|
||||
existing.userText = queryMatch ? queryMatch[1].trim().slice(0, 500) : text.slice(0, 500)
|
||||
}
|
||||
sessions.set(requestId, existing)
|
||||
} else if (row.role === 'assistant') {
|
||||
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' }
|
||||
existing.outputChars += textLength
|
||||
if (model) existing.model = model
|
||||
sessions.set(requestId, existing)
|
||||
} else if (row.role === 'tool' || row.role === 'system') {
|
||||
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' }
|
||||
existing.inputChars += textLength
|
||||
sessions.set(requestId, existing)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [requestId, session] of sessions) {
|
||||
if (session.inputChars === 0 && session.outputChars === 0) continue
|
||||
|
||||
const inputTokens = Math.ceil(session.inputChars / CHARS_PER_TOKEN)
|
||||
const outputTokens = Math.ceil(session.outputChars / CHARS_PER_TOKEN)
|
||||
const dedupKey = `cursor:agentKv:${requestId}`
|
||||
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const pricingModel = resolveModel(session.model)
|
||||
const displayModel = modelForDisplay(session.model)
|
||||
const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0)
|
||||
|
||||
results.push({
|
||||
provider: 'cursor',
|
||||
model: displayModel,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: agentKvTimestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: session.userText,
|
||||
sessionId: requestId,
|
||||
})
|
||||
}
|
||||
|
||||
return { calls: results }
|
||||
}
|
||||
|
||||
function createParser(
|
||||
source: SessionSource,
|
||||
seenKeys: Set<string>,
|
||||
|
|
@ -845,7 +779,8 @@ function createParser(
|
|||
// which double-counted against the bubble stream. parseAgentKv is
|
||||
// kept for the tools/bash breakdown in a follow-up.
|
||||
const composerInput = loadComposerInputTokens(db)
|
||||
const { calls: bubbleCalls } = parseBubbles(db, localSeen, timeFloor, composerInput)
|
||||
const agentTools = loadAgentToolsByComposer(db)
|
||||
const { calls: bubbleCalls } = parseBubbles(db, localSeen, timeFloor, composerInput, agentTools)
|
||||
allCalls = bubbleCalls
|
||||
await writeCachedResults(dbPath, allCalls, timeFloor)
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue