mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-28 18:35:29 +00:00
doctor: surface Claude Code transcript retention (#757)
Claude Code deletes session transcripts after cleanupPeriodDays (default 30) at startup. Daily totals survive in the daily cache, but per-session detail older than the cutoff is unrecoverable, and most users learn this only after losing months of history. Doctor now reports the effective retention and, below one year, warns with the exact settings.json edit that preserves transcripts.
This commit is contained in:
parent
380be66b4f
commit
e99ee4bf37
2 changed files with 123 additions and 2 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import { existsSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
import { Chalk } from 'chalk'
|
||||
|
||||
import { getClaudeConfigDirs } from './providers/claude.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import type { Provider } from './providers/types.js'
|
||||
import {
|
||||
|
|
@ -56,9 +58,23 @@ export type DoctorProviderReport = {
|
|||
error?: string
|
||||
}
|
||||
|
||||
export type ClaudeRetentionNote = {
|
||||
/// Effective transcript retention in days. Claude Code deletes session
|
||||
/// files older than cleanupPeriodDays at startup; 30 is its default when
|
||||
/// the setting is absent.
|
||||
effectiveDays: number
|
||||
/// True when cleanupPeriodDays is explicitly set in settings.json.
|
||||
configured: boolean
|
||||
settingsPath: string
|
||||
}
|
||||
|
||||
export type DoctorReport = {
|
||||
generatedAt: string
|
||||
providers: DoctorProviderReport[]
|
||||
/// Present when the Claude provider is in the report and a config dir was
|
||||
/// found. Surfaced because deleted transcripts are unrecoverable: daily
|
||||
/// totals survive in CodeBurn's cache, but per-session detail does not.
|
||||
claudeRetention?: ClaudeRetentionNote
|
||||
}
|
||||
|
||||
export type CollectDoctorOptions = {
|
||||
|
|
@ -283,13 +299,48 @@ export async function collectDoctorReport(
|
|||
}
|
||||
providers.sort((a, b) => (a.displayName < b.displayName ? -1 : a.displayName > b.displayName ? 1 : 0))
|
||||
|
||||
return { generatedAt: new Date().toISOString(), providers }
|
||||
const report: DoctorReport = { generatedAt: new Date().toISOString(), providers }
|
||||
if (providers.some(p => p.provider === 'claude')) {
|
||||
const retention = await collectClaudeRetention()
|
||||
if (retention) report.claudeRetention = retention
|
||||
}
|
||||
return report
|
||||
} finally {
|
||||
if (prevSuppress === undefined) delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
|
||||
else process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] = prevSuppress
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code's documented default when cleanupPeriodDays is absent.
|
||||
const CLAUDE_DEFAULT_CLEANUP_DAYS = 30
|
||||
// Below this, long-horizon views depend entirely on CodeBurn's daily cache;
|
||||
// the doctor line turns into a warning.
|
||||
const CLAUDE_RETENTION_WARN_DAYS = 365
|
||||
|
||||
async function collectClaudeRetention(): Promise<ClaudeRetentionNote | undefined> {
|
||||
for (const dir of await getClaudeConfigDirs()) {
|
||||
const settingsPath = join(dir, 'settings.json')
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(settingsPath, 'utf-8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const days = (parsed as Record<string, unknown> | null)?.['cleanupPeriodDays']
|
||||
if (typeof days === 'number' && Number.isFinite(days)) {
|
||||
return { effectiveDays: days, configured: true, settingsPath }
|
||||
}
|
||||
return { effectiveDays: CLAUDE_DEFAULT_CLEANUP_DAYS, configured: false, settingsPath }
|
||||
} catch {
|
||||
// Unparseable settings: report the default; Claude Code would apply it too.
|
||||
return { effectiveDays: CLAUDE_DEFAULT_CLEANUP_DAYS, configured: false, settingsPath }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderDoctorJson(report: DoctorReport): string {
|
||||
|
|
@ -364,6 +415,22 @@ export function renderDoctorTable(
|
|||
}
|
||||
}
|
||||
|
||||
if (report.claudeRetention) {
|
||||
const r = report.claudeRetention
|
||||
const source = r.configured ? 'cleanupPeriodDays' : 'cleanupPeriodDays not set; Claude Code default'
|
||||
const line = `Claude Code deletes transcripts after ${r.effectiveDays} day${r.effectiveDays === 1 ? '' : 's'} (${source}).`
|
||||
out.push('')
|
||||
if (r.effectiveDays < CLAUDE_RETENTION_WARN_DAYS) {
|
||||
out.push(
|
||||
c.yellow(line) + ' ' +
|
||||
`Daily totals survive in CodeBurn's cache, but per-session detail older than that is gone for good. ` +
|
||||
`To keep it, set "cleanupPeriodDays": 3650 in ${r.settingsPath}.`,
|
||||
)
|
||||
} else {
|
||||
out.push(c.dim(line + ' Long transcript retention: per-session detail is preserved.'))
|
||||
}
|
||||
}
|
||||
|
||||
out.push('')
|
||||
const broken = report.providers.filter(r => r.status === 'error' || r.status === 'errors')
|
||||
const empty = report.providers.filter(r => r.status === 'empty')
|
||||
|
|
|
|||
|
|
@ -328,3 +328,57 @@ describe('doctor is inert', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Claude transcript retention note ───────────────────────────────────────
|
||||
describe('collectDoctorReport - claude retention note', () => {
|
||||
async function withClaudeConfig(settings: string | null, fn: () => Promise<void>): Promise<void> {
|
||||
const prev = process.env['CLAUDE_CONFIG_DIR']
|
||||
const prevMulti = process.env['CLAUDE_CONFIG_DIRS']
|
||||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
const dir = join(tmpDir, 'claude-config')
|
||||
await mkdir(dir, { recursive: true })
|
||||
if (settings !== null) await writeFile(join(dir, 'settings.json'), settings)
|
||||
process.env['CLAUDE_CONFIG_DIR'] = dir
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['CLAUDE_CONFIG_DIR']
|
||||
else process.env['CLAUDE_CONFIG_DIR'] = prev
|
||||
if (prevMulti !== undefined) process.env['CLAUDE_CONFIG_DIRS'] = prevMulti
|
||||
}
|
||||
}
|
||||
|
||||
it('reports an explicit cleanupPeriodDays and renders it as preserved when long', async () => {
|
||||
await withClaudeConfig(JSON.stringify({ cleanupPeriodDays: 3650 }), async () => {
|
||||
const provider = fakeProvider({ name: 'claude' })
|
||||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||||
expect(report.claudeRetention).toMatchObject({ effectiveDays: 3650, configured: true })
|
||||
const table = renderDoctorTable(report, { color: false })
|
||||
expect(table).toContain('deletes transcripts after 3650 days')
|
||||
expect(table).toContain('per-session detail is preserved')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the 30-day default and renders a warning with the settings path', async () => {
|
||||
await withClaudeConfig(JSON.stringify({ theme: 'dark' }), async () => {
|
||||
const provider = fakeProvider({ name: 'claude' })
|
||||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||||
expect(report.claudeRetention).toMatchObject({ effectiveDays: 30, configured: false })
|
||||
const table = renderDoctorTable(report, { color: false })
|
||||
expect(table).toContain('deletes transcripts after 30 days')
|
||||
expect(table).toContain('cleanupPeriodDays not set')
|
||||
expect(table).toContain('"cleanupPeriodDays": 3650')
|
||||
expect(table).toContain(report.claudeRetention!.settingsPath)
|
||||
})
|
||||
})
|
||||
|
||||
it('emits no note when claude is not in the report or has no settings file', async () => {
|
||||
await withClaudeConfig(null, async () => {
|
||||
const claudeless = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'codex' })], cache: emptyCache() })
|
||||
expect(claudeless.claudeRetention).toBeUndefined()
|
||||
const noSettings = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'claude' })], cache: emptyCache() })
|
||||
expect(noSettings.claudeRetention).toBeUndefined()
|
||||
expect(renderDoctorTable(noSettings, { color: false })).not.toContain('deletes transcripts')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue