Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
claude-code history.ts
14 kB · 464 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465import { appendFile, writeFile } from 'fs/promises'import { join } from 'path'import { getProjectRoot, getSessionId } from './bootstrap/state.js'import { registerCleanup } from './utils/cleanupRegistry.js'import type { HistoryEntry, PastedContent } from './utils/config.js'import { logForDebugging } from './utils/debug.js'import { getClaudeConfigHomeDir, isEnvTruthy } from './utils/envUtils.js'import { getErrnoCode } from './utils/errors.js'import { readLinesReverse } from './utils/fsOperations.js'import { lock } from './utils/lockfile.js'import { hashPastedText, retrievePastedText, storePastedText,} from './utils/pasteStore.js'import { sleep } from './utils/sleep.js'import { jsonParse, jsonStringify } from './utils/slowOperations.js'
const MAX_HISTORY_ITEMS = 100const MAX_PASTED_CONTENT_LENGTH = 1024
/** * Stored paste content - either inline content or a hash reference to paste store. */type StoredPastedContent = { id: number type: 'text' | 'image' content?: string // Inline content for small pastes contentHash?: string // Hash reference for large pastes stored externally mediaType?: string filename?: string}
/** * Claude Code parses history for pasted content references to match back to * pasted content. The references look like: * Text: [Pasted text #1 +10 lines] * Image: [Image #2] * The numbers are expected to be unique within a single prompt but not across * prompts. We choose numeric, auto-incrementing IDs as they are more * user-friendly than other ID options. */
// Note: The original text paste implementation would consider input like// "line1\nline2\nline3" to have +2 lines, not 3 lines. We preserve that// behavior here.export function getPastedTextRefNumLines(text: string): number { return (text.match(/\r\n|\r|\n/g) || []).length}
export function formatPastedTextRef(id: number, numLines: number): string { if (numLines === 0) { return `[Pasted text #${id}]` } return `[Pasted text #${id} +${numLines} lines]`}
export function formatImageRef(id: number): string { return `[Image #${id}]`}
export function parseReferences( input: string,): Array<{ id: number; match: string; index: number }> { const referencePattern = /\[(Pasted text|Image|\.\.\.Truncated text) #(\d+)(?: \+\d+ lines)?(\.)*\]/g const matches = [...input.matchAll(referencePattern)] return matches .map(match => ({ id: parseInt(match[2] || '0'), match: match[0], index: match.index, })) .filter(match => match.id > 0)}
/** * Replace [Pasted text #N] placeholders in input with their actual content. * Image refs are left alone — they become content blocks, not inlined text. */export function expandPastedTextRefs( input: string, pastedContents: Record<number, PastedContent>,): string { const refs = parseReferences(input) let expanded = input // Splice at the original match offsets so placeholder-like strings inside // pasted content are never confused for real refs. Reverse order keeps // earlier offsets valid after later replacements. for (let i = refs.length - 1; i >= 0; i--) { const ref = refs[i]! const content = pastedContents[ref.id] if (content?.type !== 'text') continue expanded = expanded.slice(0, ref.index) + content.content + expanded.slice(ref.index + ref.match.length) } return expanded}
function deserializeLogEntry(line: string): LogEntry { return jsonParse(line) as LogEntry}
async function* makeLogEntryReader(): AsyncGenerator<LogEntry> { const currentSession = getSessionId()
// Start with entries that have yet to be flushed to disk for (let i = pendingEntries.length - 1; i >= 0; i--) { yield pendingEntries[i]! }
// Read from global history file (shared across all projects) const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl')
try { for await (const line of readLinesReverse(historyPath)) { try { const entry = deserializeLogEntry(line) // removeLastFromHistory slow path: entry was flushed before removal, // so filter here so both getHistory (Up-arrow) and makeHistoryReader // (ctrl+r search) skip it consistently. if ( entry.sessionId === currentSession && skippedTimestamps.has(entry.timestamp) ) { continue } yield entry } catch (error) { // Not a critical error - just skip malformed lines logForDebugging(`Failed to parse history line: ${error}`) } } } catch (e: unknown) { const code = getErrnoCode(e) if (code === 'ENOENT') { return } throw e }}
export async function* makeHistoryReader(): AsyncGenerator<HistoryEntry> { for await (const entry of makeLogEntryReader()) { yield await logEntryToHistoryEntry(entry) }}
export type TimestampedHistoryEntry = { display: string timestamp: number resolve: () => Promise<HistoryEntry>}
/** * Current-project history for the ctrl+r picker: deduped by display text, * newest first, with timestamps. Paste contents are resolved lazily via * `resolve()` — the picker only reads display+timestamp for the list. */export async function* getTimestampedHistory(): AsyncGenerator<TimestampedHistoryEntry> { const currentProject = getProjectRoot() const seen = new Set<string>()
for await (const entry of makeLogEntryReader()) { if (!entry || typeof entry.project !== 'string') continue if (entry.project !== currentProject) continue if (seen.has(entry.display)) continue seen.add(entry.display)
yield { display: entry.display, timestamp: entry.timestamp, resolve: () => logEntryToHistoryEntry(entry), }
if (seen.size >= MAX_HISTORY_ITEMS) return }}
/** * Get history entries for the current project, with current session's entries first. * * Entries from the current session are yielded before entries from other sessions, * so concurrent sessions don't interleave their up-arrow history. Within each group, * order is newest-first. Scans the same MAX_HISTORY_ITEMS window as before — * entries are reordered within that window, not beyond it. */export async function* getHistory(): AsyncGenerator<HistoryEntry> { const currentProject = getProjectRoot() const currentSession = getSessionId() const otherSessionEntries: LogEntry[] = [] let yielded = 0
for await (const entry of makeLogEntryReader()) { // Skip malformed entries (corrupted file, old format, or invalid JSON structure) if (!entry || typeof entry.project !== 'string') continue if (entry.project !== currentProject) continue
if (entry.sessionId === currentSession) { yield await logEntryToHistoryEntry(entry) yielded++ } else { otherSessionEntries.push(entry) }
// Same MAX_HISTORY_ITEMS window as before — just reordered within it. if (yielded + otherSessionEntries.length >= MAX_HISTORY_ITEMS) break }
for (const entry of otherSessionEntries) { if (yielded >= MAX_HISTORY_ITEMS) return yield await logEntryToHistoryEntry(entry) yielded++ }}
type LogEntry = { display: string pastedContents: Record<number, StoredPastedContent> timestamp: number project: string sessionId?: string}
/** * Resolve stored paste content to full PastedContent by fetching from paste store if needed. */async function resolveStoredPastedContent( stored: StoredPastedContent,): Promise<PastedContent | null> { // If we have inline content, use it directly if (stored.content) { return { id: stored.id, type: stored.type, content: stored.content, mediaType: stored.mediaType, filename: stored.filename, } }
// If we have a hash reference, fetch from paste store if (stored.contentHash) { const content = await retrievePastedText(stored.contentHash) if (content) { return { id: stored.id, type: stored.type, content, mediaType: stored.mediaType, filename: stored.filename, } } }
// Content not available return null}
/** * Convert LogEntry to HistoryEntry by resolving paste store references. */async function logEntryToHistoryEntry(entry: LogEntry): Promise<HistoryEntry> { const pastedContents: Record<number, PastedContent> = {}
for (const [id, stored] of Object.entries(entry.pastedContents || {})) { const resolved = await resolveStoredPastedContent(stored) if (resolved) { pastedContents[Number(id)] = resolved } }
return { display: entry.display, pastedContents, }}
let pendingEntries: LogEntry[] = []let isWriting = falselet currentFlushPromise: Promise<void> | null = nulllet cleanupRegistered = falselet lastAddedEntry: LogEntry | null = null// Timestamps of entries already flushed to disk that should be skipped when// reading. Used by removeLastFromHistory when the entry has raced past the// pending buffer. Session-scoped (module state resets on process restart).const skippedTimestamps = new Set<number>()
// Core flush logic - writes pending entries to diskasync function immediateFlushHistory(): Promise<void> { if (pendingEntries.length === 0) { return }
let release try { const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl')
// Ensure the file exists before acquiring lock (append mode creates if missing) await writeFile(historyPath, '', { encoding: 'utf8', mode: 0o600, flag: 'a', })
release = await lock(historyPath, { stale: 10000, retries: { retries: 3, minTimeout: 50, }, })
const jsonLines = pendingEntries.map(entry => jsonStringify(entry) + '\n') pendingEntries = []
await appendFile(historyPath, jsonLines.join(''), { mode: 0o600 }) } catch (error) { logForDebugging(`Failed to write prompt history: ${error}`) } finally { if (release) { await release() } }}
async function flushPromptHistory(retries: number): Promise<void> { if (isWriting || pendingEntries.length === 0) { return }
// Stop trying to flush history until the next user prompt if (retries > 5) { return }
isWriting = true
try { await immediateFlushHistory() } finally { isWriting = false
if (pendingEntries.length > 0) { // Avoid trying again in a hot loop await sleep(500)
void flushPromptHistory(retries + 1) } }}
async function addToPromptHistory( command: HistoryEntry | string,): Promise<void> { const entry = typeof command === 'string' ? { display: command, pastedContents: {} } : command
const storedPastedContents: Record<number, StoredPastedContent> = {} if (entry.pastedContents) { for (const [id, content] of Object.entries(entry.pastedContents)) { // Filter out images (they're stored separately in image-cache) if (content.type === 'image') { continue }
// For small text content, store inline if (content.content.length <= MAX_PASTED_CONTENT_LENGTH) { storedPastedContents[Number(id)] = { id: content.id, type: content.type, content: content.content, mediaType: content.mediaType, filename: content.filename, } } else { // For large text content, compute hash synchronously and store reference // The actual disk write happens async (fire-and-forget) const hash = hashPastedText(content.content) storedPastedContents[Number(id)] = { id: content.id, type: content.type, contentHash: hash, mediaType: content.mediaType, filename: content.filename, } // Fire-and-forget disk write - don't block history entry creation void storePastedText(hash, content.content) } } }
const logEntry: LogEntry = { ...entry, pastedContents: storedPastedContents, timestamp: Date.now(), project: getProjectRoot(), sessionId: getSessionId(), }
pendingEntries.push(logEntry) lastAddedEntry = logEntry currentFlushPromise = flushPromptHistory(0) void currentFlushPromise}
export function addToHistory(command: HistoryEntry | string): void { // Skip history when running in a tmux session spawned by Claude Code's Tungsten tool. // This prevents verification/test sessions from polluting the user's real command history. if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY)) { return }
// Register cleanup on first use if (!cleanupRegistered) { cleanupRegistered = true registerCleanup(async () => { // If there's an in-progress flush, wait for it if (currentFlushPromise) { await currentFlushPromise } // If there are still pending entries after the flush completed, do one final flush if (pendingEntries.length > 0) { await immediateFlushHistory() } }) }
void addToPromptHistory(command)}
export function clearPendingHistoryEntries(): void { pendingEntries = [] lastAddedEntry = null skippedTimestamps.clear()}
/** * Undo the most recent addToHistory call. Used by auto-restore-on-interrupt: * when Esc rewinds the conversation before any response arrives, the submit is * semantically undone — the history entry should be too, otherwise Up-arrow * shows the restored text twice (once from the input box, once from disk). * * Fast path pops from the pending buffer. If the async flush already won the * race (TTFT is typically >> disk write latency), the entry's timestamp is * added to a skip-set consulted by getHistory. One-shot: clears the tracked * entry so a second call is a no-op. */export function removeLastFromHistory(): void { if (!lastAddedEntry) return const entry = lastAddedEntry lastAddedEntry = null
const idx = pendingEntries.lastIndexOf(entry) if (idx !== -1) { pendingEntries.splice(idx, 1) } else { skippedTimestamps.add(entry.timestamp) }}