1- import { existsSync , statSync , readdirSync , readFileSync } from 'fs'
1+ import { existsSync , readdirSync , readFileSync } from 'fs'
22import { join } from 'path'
33import { 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 ( / < u s e r _ q u e r y > ( [ \s \S ] * ?) < \/ u s e r _ q u e r y > / )
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 {
0 commit comments