Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,14 @@ export async function ensureCacheHydrated(
}
}

const hadYesterday = c.days.some(d => d.date >= yesterdayStr)
if (hadYesterday) {
const freshDays = c.days.filter(d => d.date < yesterdayStr)
// Drop any cached entry dated today or later. The cache only ever stores
// complete past days (up to yesterday), so a >= today entry can only come
// from the clock moving backward or a stale older cache; left in place it
// would be served frozen instead of recomputed live. Yesterday and earlier
// stay cached, so this does not re-parse already-cached days.
const todayStr = toDateString(now)
if (c.days.some(d => d.date >= todayStr)) {
const freshDays = c.days.filter(d => d.date < todayStr)
const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
}
Expand Down
6 changes: 5 additions & 1 deletion src/providers/cursor-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i
const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i
const USER_QUERY_OPEN = '<user_query>'
const USER_QUERY_CLOSE = '</user_query>'
const warnedUnrecognizedTranscripts = new Set<string>()
const CONVERSATION_SUMMARY_QUERY = `
SELECT conversationId, model, title, updatedAt
FROM conversation_summaries
Expand Down Expand Up @@ -360,7 +361,10 @@ function createParser(
const parsed = isJsonl ? parseJsonlTranscript(transcript) : parseTranscript(transcript)

if (!parsed.recognized) {
process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`)
if (!warnedUnrecognizedTranscripts.has(source.path)) {
warnedUnrecognizedTranscripts.add(source.path)
process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`)
}
return
}

Expand Down
60 changes: 58 additions & 2 deletions tests/daily-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFile, rm } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
Expand All @@ -11,8 +11,8 @@ import {
DAILY_CACHE_VERSION,
type DailyCache,
type DailyEntry,
ensureCacheHydrated,
getDaysInRange,
ensureCacheHydrated,
loadDailyCache,
saveDailyCache,
withDailyCacheLock,
Expand Down Expand Up @@ -44,6 +44,7 @@ beforeEach(() => {
})

afterEach(async () => {
vi.useRealTimers()
delete process.env['CODEBURN_CACHE_DIR']
if (existsSync(TMP_CACHE_ROOT)) {
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
Expand Down Expand Up @@ -267,6 +268,61 @@ describe('getDaysInRange', () => {
})
})

describe('ensureCacheHydrated', () => {
it('does not recompute yesterday after it has already been cached', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))

const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 5, 10)],
}
await saveDailyCache(saved)

let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [],
)

expect(parseCalls).toBe(0)
expect(hydrated).toEqual(saved)
})

it('drops a cached today/future entry so it is recomputed live, keeping yesterday cached', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))

// A "today" entry can only exist via a backward clock change or a stale
// cache; it must be purged so today is served live, not from a frozen entry.
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-06-12',
days: [emptyDay('2026-06-11', 5, 10), emptyDay('2026-06-12', 9, 20)],
}
await saveDailyCache(saved)

let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [],
)

expect(parseCalls).toBe(0)
expect(hydrated.days.map(d => d.date)).toEqual(['2026-06-11'])
expect(hydrated.lastComputedDate).toBe('2026-06-11')
})
})

describe('withDailyCacheLock', () => {
it('serializes concurrent operations', async () => {
const sequence: string[] = []
Expand Down
22 changes: 22 additions & 0 deletions tests/providers/cursor-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ describe('cursor-agent provider', () => {
stderrSpy.mockRestore()
})

it('warns only once for the same unrecognized transcript', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'bad-proj-repeat', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const transcriptPath = join(transcriptDir, 'repeat-bad.txt')
await writeFile(transcriptPath, 'no cursor-agent markers here')

const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)

await collectCalls(provider, source)
await collectCalls(provider, source)

const warnings = stderrSpy.mock.calls
.map(call => String(call[0] ?? ''))
.filter(message => message.includes('unrecognized cursor-agent transcript format'))
expect(warnings).toHaveLength(1)

stderrSpy.mockRestore()
})

it('falls back to stable sha1 conversation id for non-uuid filenames', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'sha-proj', 'agent-transcripts')
Expand Down