Skip to content

Commit c1c96bb

Browse files
committed
Fix Cursor agentKv timestamp attribution
1 parent 5a837c9 commit c1c96bb

4 files changed

Lines changed: 201 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
4343
child and grandchild agent sessions contribute token and tool usage under
4444
the discovered root session while still excluding child sessions from
4545
top-level discovery to avoid double counting.
46+
- **Cursor agentKv timestamps no longer use database mtime.** Cursor agentKv
47+
rows now require an internal timestamp (`createdAt`, `timestamp`, or `time`)
48+
before CodeBurn reports usage for that session. Rows without an internal
49+
timestamp are skipped instead of being attributed to the mutable SQLite file
50+
modification time, preventing historical Cursor usage from appearing under
51+
today's date. Cursor result cache version bumped to recompute older cached
52+
entries. Closes #325.
4653

4754
## 0.9.9 - 2026-05-15
4855

src/cursor-cache.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@ import { randomBytes } from 'crypto'
55

66
import type { ParsedProviderCall } from './providers/types.js'
77

8-
// Bumped to 3 for the workspace-aware breakdown change: the cursor parser
9-
// now derives `sessionId` from the bubble row key (the real composer id)
10-
// rather than the empty `conversationId` JSON field, and the workspace
11-
// router relies on those composer ids to bucket calls per project.
12-
// Version 2 caches contain `sessionId: 'unknown'` for every call and would
13-
// route everything to the orphan project, so we invalidate them.
14-
const CURSOR_CACHE_VERSION = 3
8+
// Bumped to 4 for the Cursor timestamp hardening: agentKv calls now require
9+
// an internal row timestamp instead of using the mutable SQLite database mtime.
10+
// Version 3 caches can contain historical agentKv calls bucketed under the
11+
// database modification day, so they must be invalidated.
12+
const CURSOR_CACHE_VERSION = 4
1513

1614
type ResultCache = {
1715
version?: number
@@ -23,7 +21,7 @@ type ResultCache = {
2321
const CACHE_FILE = 'cursor-results.json'
2422

2523
function getCacheDir(): string {
26-
return join(homedir(), '.cache', 'codeburn')
24+
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
2725
}
2826

2927
function getCachePath(): string {

src/providers/cursor.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, statSync, readdirSync, readFileSync } from 'fs'
1+
import { existsSync, readdirSync, readFileSync } from 'fs'
22
import { join } from 'path'
33
import { homedir } from 'os'
44

@@ -44,6 +44,7 @@ type AgentKvRow = {
4444
role: string | null
4545
content: Uint8Array | string | null
4646
request_id: string | null
47+
created_at: string | number | null
4748
content_length: number
4849
}
4950

@@ -305,6 +306,11 @@ const AGENTKV_QUERY = `
305306
json_extract(value, '$.role') as role,
306307
CAST(json_extract(value, '$.content') AS BLOB) as content,
307308
json_extract(value, '$.providerOptions.cursor.requestId') as request_id,
309+
COALESCE(
310+
json_extract(value, '$.createdAt'),
311+
json_extract(value, '$.timestamp'),
312+
json_extract(value, '$.time')
313+
) as created_at,
308314
length(value) as content_length
309315
FROM cursorDiskKV
310316
WHERE key LIKE 'agentKv:blob:%'
@@ -547,20 +553,18 @@ function extractTextLength(content: AgentKvContent[]): number {
547553
return total
548554
}
549555

550-
function parseAgentKv(db: SqliteDatabase, seenKeys: Set<string>, dbPath: string): { calls: ParsedProviderCall[] } {
551-
const results: ParsedProviderCall[] = []
556+
function parseCursorTimestamp(raw: string | number | null | undefined): string | null {
557+
if (raw === null || raw === undefined || raw === '') return null
558+
const numeric = typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : raw
559+
const date = typeof numeric === 'number' && numeric < 1_000_000_000_000
560+
? new Date(numeric * 1000)
561+
: new Date(numeric)
562+
if (Number.isNaN(date.getTime())) return null
563+
return date.toISOString()
564+
}
552565

553-
// Cursor's agentKv schema does not record per-message timestamps. Use the
554-
// SQLite file's mtime as a bounded "last write" timestamp for all calls;
555-
// it's at least honest (no future time, no always-now). Users running
556-
// codeburn against an idle Cursor install will see agentKv calls land at
557-
// the actual last activity time rather than today's date.
558-
let agentKvTimestamp: string
559-
try {
560-
agentKvTimestamp = new Date(statSync(dbPath).mtimeMs).toISOString()
561-
} catch {
562-
agentKvTimestamp = new Date().toISOString()
563-
}
566+
function parseAgentKv(db: SqliteDatabase, seenKeys: Set<string>): { calls: ParsedProviderCall[] } {
567+
const results: ParsedProviderCall[] = []
564568

565569
let rows: AgentKvRow[]
566570
try {
@@ -569,9 +573,10 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set<string>, dbPath: string)
569573
return { calls: results }
570574
}
571575

572-
const sessions: Map<string, { inputChars: number; outputChars: number; model: string | null; userText: string }> = new Map()
576+
const sessions: Map<string, { inputChars: number; outputChars: number; model: string | null; userText: string; timestamp: string | null }> = new Map()
573577
let currentRequestId = 'unknown'
574578
let turnIndex = 0
579+
let skippedMissingTimestamp = 0
575580

576581
for (const row of rows) {
577582
if (!row.role || !row.content) continue
@@ -600,30 +605,38 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set<string>, dbPath: string)
600605

601606
const textLength = plainTextLength || extractTextLength(content)
602607
const model = extractModelFromContent(content)
608+
const timestamp = parseCursorTimestamp(row.created_at)
603609

604610
if (row.role === 'user') {
605-
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' }
611+
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '', timestamp: null }
606612
existing.inputChars += textLength
613+
if (!existing.timestamp && timestamp) existing.timestamp = timestamp
607614
if (!existing.userText) {
608615
const text = content[0]?.text ?? contentText
609616
const queryMatch = text.match(/<user_query>([\s\S]*?)<\/user_query>/)
610617
existing.userText = queryMatch ? queryMatch[1].trim().slice(0, 500) : text.slice(0, 500)
611618
}
612619
sessions.set(requestId, existing)
613620
} else if (row.role === 'assistant') {
614-
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' }
621+
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '', timestamp: null }
615622
existing.outputChars += textLength
623+
if (!existing.timestamp && timestamp) existing.timestamp = timestamp
616624
if (model) existing.model = model
617625
sessions.set(requestId, existing)
618626
} else if (row.role === 'tool' || row.role === 'system') {
619-
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' }
627+
const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '', timestamp: null }
620628
existing.inputChars += textLength
629+
if (!existing.timestamp && timestamp) existing.timestamp = timestamp
621630
sessions.set(requestId, existing)
622631
}
623632
}
624633

625634
for (const [requestId, session] of sessions) {
626635
if (session.inputChars === 0 && session.outputChars === 0) continue
636+
if (!session.timestamp) {
637+
skippedMissingTimestamp += 1
638+
continue
639+
}
627640

628641
const inputTokens = Math.ceil(session.inputChars / CHARS_PER_TOKEN)
629642
const outputTokens = Math.ceil(session.outputChars / CHARS_PER_TOKEN)
@@ -649,14 +662,18 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set<string>, dbPath: string)
649662
costUSD,
650663
tools: [],
651664
bashCommands: [],
652-
timestamp: agentKvTimestamp,
665+
timestamp: session.timestamp,
653666
speed: 'standard',
654667
deduplicationKey: dedupKey,
655668
userMessage: session.userText,
656669
sessionId: requestId,
657670
})
658671
}
659672

673+
if (skippedMissingTimestamp > 0) {
674+
process.stderr.write(`codeburn: skipped ${skippedMissingTimestamp} Cursor agentKv sessions without internal timestamps\n`)
675+
}
676+
660677
return { calls: results }
661678
}
662679

@@ -720,7 +737,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
720737
// about to drop. Cross-source dedup happens at yield time.
721738
const localSeen = new Set<string>()
722739
const { calls: bubbleCalls } = parseBubbles(db, localSeen)
723-
const { calls: agentKvCalls } = parseAgentKv(db, localSeen, dbPath)
740+
const { calls: agentKvCalls } = parseAgentKv(db, localSeen)
724741
allCalls = [...bubbleCalls, ...agentKvCalls]
725742
await writeCachedResults(dbPath, allCalls)
726743
} finally {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { mkdtemp, rm, utimes, writeFile } from 'fs/promises'
3+
import { join } from 'path'
4+
import { tmpdir } from 'os'
5+
import { createRequire } from 'node:module'
6+
7+
import { createCursorProvider } from '../../src/providers/cursor.js'
8+
import { isSqliteAvailable } from '../../src/sqlite.js'
9+
import type { ParsedProviderCall } from '../../src/providers/types.js'
10+
11+
const requireForTest = createRequire(import.meta.url)
12+
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
13+
14+
let tmpDir: string
15+
let oldCacheDir: string | undefined
16+
17+
beforeEach(async () => {
18+
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-agentkv-timestamp-'))
19+
oldCacheDir = process.env['CODEBURN_CACHE_DIR']
20+
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
21+
})
22+
23+
afterEach(async () => {
24+
if (oldCacheDir === undefined) {
25+
delete process.env['CODEBURN_CACHE_DIR']
26+
} else {
27+
process.env['CODEBURN_CACHE_DIR'] = oldCacheDir
28+
}
29+
await rm(tmpDir, { recursive: true, force: true })
30+
})
31+
32+
function agentKvValue(opts: {
33+
role: 'user' | 'assistant'
34+
text: string
35+
requestId: string
36+
createdAt?: string | number
37+
modelName?: string
38+
}): string {
39+
return JSON.stringify({
40+
role: opts.role,
41+
...(opts.createdAt ? { createdAt: opts.createdAt } : {}),
42+
providerOptions: { cursor: { requestId: opts.requestId } },
43+
content: [{
44+
text: opts.text,
45+
...(opts.modelName ? { providerOptions: { cursor: { modelName: opts.modelName } } } : {}),
46+
}],
47+
})
48+
}
49+
50+
async function createAgentKvDb(rows: Array<{ key: string; value: string }>): Promise<string> {
51+
const dbPath = join(tmpDir, 'state.vscdb')
52+
await writeFile(dbPath, '')
53+
const { DatabaseSync: Database } = requireForTest('node:sqlite') as {
54+
DatabaseSync: new (path: string) => {
55+
exec(sql: string): void
56+
prepare(sql: string): { run(...params: unknown[]): void }
57+
close(): void
58+
}
59+
}
60+
const db = new Database(dbPath)
61+
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)')
62+
const insert = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)')
63+
for (const row of rows) insert.run(row.key, row.value)
64+
db.close()
65+
return dbPath
66+
}
67+
68+
async function collectCursorCalls(dbPath: string): Promise<ParsedProviderCall[]> {
69+
const provider = createCursorProvider(dbPath)
70+
const source = { path: dbPath, project: 'cursor', provider: 'cursor' }
71+
const calls: ParsedProviderCall[] = []
72+
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
73+
return calls
74+
}
75+
76+
skipUnlessSqlite('cursor agentKv timestamps', () => {
77+
it('skips agentKv sessions without internal timestamps instead of using database mtime', async () => {
78+
const dbPath = await createAgentKvDb([
79+
{
80+
key: 'agentKv:blob:req-1:user',
81+
value: agentKvValue({ role: 'user', requestId: 'req-1', text: '<user_query>old task</user_query>' }),
82+
},
83+
{
84+
key: 'agentKv:blob:req-1:assistant',
85+
value: agentKvValue({ role: 'assistant', requestId: 'req-1', text: 'old answer', modelName: 'gpt-5' }),
86+
},
87+
])
88+
await utimes(dbPath, new Date('2099-01-01T00:00:00.000Z'), new Date('2099-01-01T00:00:00.000Z'))
89+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
90+
91+
try {
92+
const calls = await collectCursorCalls(dbPath)
93+
94+
expect(calls).toHaveLength(0)
95+
expect(String(stderrSpy.mock.calls.at(-1)?.[0] ?? '')).toContain('without internal timestamps')
96+
} finally {
97+
stderrSpy.mockRestore()
98+
}
99+
})
100+
101+
it('uses agentKv internal createdAt when present', async () => {
102+
const createdAt = '2025-01-02T03:04:05.000Z'
103+
const dbPath = await createAgentKvDb([
104+
{
105+
key: 'agentKv:blob:req-2:user',
106+
value: agentKvValue({ role: 'user', requestId: 'req-2', text: '<user_query>old task</user_query>', createdAt }),
107+
},
108+
{
109+
key: 'agentKv:blob:req-2:assistant',
110+
value: agentKvValue({ role: 'assistant', requestId: 'req-2', text: 'old answer', modelName: 'gpt-5', createdAt }),
111+
},
112+
])
113+
await utimes(dbPath, new Date('2099-01-01T00:00:00.000Z'), new Date('2099-01-01T00:00:00.000Z'))
114+
115+
const calls = await collectCursorCalls(dbPath)
116+
117+
expect(calls).toHaveLength(1)
118+
expect(calls[0]!.timestamp).toBe(createdAt)
119+
expect(calls[0]!.deduplicationKey).toBe('cursor:agentKv:req-2')
120+
expect(calls[0]!.model).toBe('gpt-5')
121+
})
122+
123+
it('accepts numeric agentKv timestamps stored as JSON strings', async () => {
124+
const dbPath = await createAgentKvDb([
125+
{
126+
key: 'agentKv:blob:req-3:user',
127+
value: agentKvValue({
128+
role: 'user',
129+
requestId: 'req-3',
130+
text: '<user_query>old task</user_query>',
131+
createdAt: '1735787045',
132+
}),
133+
},
134+
{
135+
key: 'agentKv:blob:req-3:assistant',
136+
value: agentKvValue({
137+
role: 'assistant',
138+
requestId: 'req-3',
139+
text: 'old answer',
140+
modelName: 'gpt-5',
141+
createdAt: '1735787045',
142+
}),
143+
},
144+
])
145+
146+
const calls = await collectCursorCalls(dbPath)
147+
148+
expect(calls).toHaveLength(1)
149+
expect(calls[0]!.timestamp).toBe('2025-01-02T03:04:05.000Z')
150+
})
151+
})

0 commit comments

Comments
 (0)