/ history.ts
history.ts
  1  import { appendFile, writeFile } from 'fs/promises'
  2  import { join } from 'path'
  3  import { getProjectRoot, getSessionId } from './bootstrap/state.js'
  4  import { registerCleanup } from './utils/cleanupRegistry.js'
  5  import type { HistoryEntry, PastedContent } from './utils/config.js'
  6  import { logForDebugging } from './utils/debug.js'
  7  import { getClaudeConfigHomeDir, isEnvTruthy } from './utils/envUtils.js'
  8  import { getErrnoCode } from './utils/errors.js'
  9  import { readLinesReverse } from './utils/fsOperations.js'
 10  import { lock } from './utils/lockfile.js'
 11  import {
 12    hashPastedText,
 13    retrievePastedText,
 14    storePastedText,
 15  } from './utils/pasteStore.js'
 16  import { sleep } from './utils/sleep.js'
 17  import { jsonParse, jsonStringify } from './utils/slowOperations.js'
 18  
 19  const MAX_HISTORY_ITEMS = 100
 20  const MAX_PASTED_CONTENT_LENGTH = 1024
 21  
 22  /**
 23   * Stored paste content - either inline content or a hash reference to paste store.
 24   */
 25  type StoredPastedContent = {
 26    id: number
 27    type: 'text' | 'image'
 28    content?: string // Inline content for small pastes
 29    contentHash?: string // Hash reference for large pastes stored externally
 30    mediaType?: string
 31    filename?: string
 32  }
 33  
 34  /**
 35   * Claude Code parses history for pasted content references to match back to
 36   * pasted content. The references look like:
 37   *   Text: [Pasted text #1 +10 lines]
 38   *   Image: [Image #2]
 39   * The numbers are expected to be unique within a single prompt but not across
 40   * prompts. We choose numeric, auto-incrementing IDs as they are more
 41   * user-friendly than other ID options.
 42   */
 43  
 44  // Note: The original text paste implementation would consider input like
 45  // "line1\nline2\nline3" to have +2 lines, not 3 lines. We preserve that
 46  // behavior here.
 47  export function getPastedTextRefNumLines(text: string): number {
 48    return (text.match(/\r\n|\r|\n/g) || []).length
 49  }
 50  
 51  export function formatPastedTextRef(id: number, numLines: number): string {
 52    if (numLines === 0) {
 53      return `[Pasted text #${id}]`
 54    }
 55    return `[Pasted text #${id} +${numLines} lines]`
 56  }
 57  
 58  export function formatImageRef(id: number): string {
 59    return `[Image #${id}]`
 60  }
 61  
 62  export function parseReferences(
 63    input: string,
 64  ): Array<{ id: number; match: string; index: number }> {
 65    const referencePattern =
 66      /\[(Pasted text|Image|\.\.\.Truncated text) #(\d+)(?: \+\d+ lines)?(\.)*\]/g
 67    const matches = [...input.matchAll(referencePattern)]
 68    return matches
 69      .map(match => ({
 70        id: parseInt(match[2] || '0'),
 71        match: match[0],
 72        index: match.index,
 73      }))
 74      .filter(match => match.id > 0)
 75  }
 76  
 77  /**
 78   * Replace [Pasted text #N] placeholders in input with their actual content.
 79   * Image refs are left alone — they become content blocks, not inlined text.
 80   */
 81  export function expandPastedTextRefs(
 82    input: string,
 83    pastedContents: Record<number, PastedContent>,
 84  ): string {
 85    const refs = parseReferences(input)
 86    let expanded = input
 87    // Splice at the original match offsets so placeholder-like strings inside
 88    // pasted content are never confused for real refs. Reverse order keeps
 89    // earlier offsets valid after later replacements.
 90    for (let i = refs.length - 1; i >= 0; i--) {
 91      const ref = refs[i]!
 92      const content = pastedContents[ref.id]
 93      if (content?.type !== 'text') continue
 94      expanded =
 95        expanded.slice(0, ref.index) +
 96        content.content +
 97        expanded.slice(ref.index + ref.match.length)
 98    }
 99    return expanded
100  }
101  
102  function deserializeLogEntry(line: string): LogEntry {
103    return jsonParse(line) as LogEntry
104  }
105  
106  async function* makeLogEntryReader(): AsyncGenerator<LogEntry> {
107    const currentSession = getSessionId()
108  
109    // Start with entries that have yet to be flushed to disk
110    for (let i = pendingEntries.length - 1; i >= 0; i--) {
111      yield pendingEntries[i]!
112    }
113  
114    // Read from global history file (shared across all projects)
115    const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl')
116  
117    try {
118      for await (const line of readLinesReverse(historyPath)) {
119        try {
120          const entry = deserializeLogEntry(line)
121          // removeLastFromHistory slow path: entry was flushed before removal,
122          // so filter here so both getHistory (Up-arrow) and makeHistoryReader
123          // (ctrl+r search) skip it consistently.
124          if (
125            entry.sessionId === currentSession &&
126            skippedTimestamps.has(entry.timestamp)
127          ) {
128            continue
129          }
130          yield entry
131        } catch (error) {
132          // Not a critical error - just skip malformed lines
133          logForDebugging(`Failed to parse history line: ${error}`)
134        }
135      }
136    } catch (e: unknown) {
137      const code = getErrnoCode(e)
138      if (code === 'ENOENT') {
139        return
140      }
141      throw e
142    }
143  }
144  
145  export async function* makeHistoryReader(): AsyncGenerator<HistoryEntry> {
146    for await (const entry of makeLogEntryReader()) {
147      yield await logEntryToHistoryEntry(entry)
148    }
149  }
150  
151  export type TimestampedHistoryEntry = {
152    display: string
153    timestamp: number
154    resolve: () => Promise<HistoryEntry>
155  }
156  
157  /**
158   * Current-project history for the ctrl+r picker: deduped by display text,
159   * newest first, with timestamps. Paste contents are resolved lazily via
160   * `resolve()` — the picker only reads display+timestamp for the list.
161   */
162  export async function* getTimestampedHistory(): AsyncGenerator<TimestampedHistoryEntry> {
163    const currentProject = getProjectRoot()
164    const seen = new Set<string>()
165  
166    for await (const entry of makeLogEntryReader()) {
167      if (!entry || typeof entry.project !== 'string') continue
168      if (entry.project !== currentProject) continue
169      if (seen.has(entry.display)) continue
170      seen.add(entry.display)
171  
172      yield {
173        display: entry.display,
174        timestamp: entry.timestamp,
175        resolve: () => logEntryToHistoryEntry(entry),
176      }
177  
178      if (seen.size >= MAX_HISTORY_ITEMS) return
179    }
180  }
181  
182  /**
183   * Get history entries for the current project, with current session's entries first.
184   *
185   * Entries from the current session are yielded before entries from other sessions,
186   * so concurrent sessions don't interleave their up-arrow history. Within each group,
187   * order is newest-first. Scans the same MAX_HISTORY_ITEMS window as before —
188   * entries are reordered within that window, not beyond it.
189   */
190  export async function* getHistory(): AsyncGenerator<HistoryEntry> {
191    const currentProject = getProjectRoot()
192    const currentSession = getSessionId()
193    const otherSessionEntries: LogEntry[] = []
194    let yielded = 0
195  
196    for await (const entry of makeLogEntryReader()) {
197      // Skip malformed entries (corrupted file, old format, or invalid JSON structure)
198      if (!entry || typeof entry.project !== 'string') continue
199      if (entry.project !== currentProject) continue
200  
201      if (entry.sessionId === currentSession) {
202        yield await logEntryToHistoryEntry(entry)
203        yielded++
204      } else {
205        otherSessionEntries.push(entry)
206      }
207  
208      // Same MAX_HISTORY_ITEMS window as before — just reordered within it.
209      if (yielded + otherSessionEntries.length >= MAX_HISTORY_ITEMS) break
210    }
211  
212    for (const entry of otherSessionEntries) {
213      if (yielded >= MAX_HISTORY_ITEMS) return
214      yield await logEntryToHistoryEntry(entry)
215      yielded++
216    }
217  }
218  
219  type LogEntry = {
220    display: string
221    pastedContents: Record<number, StoredPastedContent>
222    timestamp: number
223    project: string
224    sessionId?: string
225  }
226  
227  /**
228   * Resolve stored paste content to full PastedContent by fetching from paste store if needed.
229   */
230  async function resolveStoredPastedContent(
231    stored: StoredPastedContent,
232  ): Promise<PastedContent | null> {
233    // If we have inline content, use it directly
234    if (stored.content) {
235      return {
236        id: stored.id,
237        type: stored.type,
238        content: stored.content,
239        mediaType: stored.mediaType,
240        filename: stored.filename,
241      }
242    }
243  
244    // If we have a hash reference, fetch from paste store
245    if (stored.contentHash) {
246      const content = await retrievePastedText(stored.contentHash)
247      if (content) {
248        return {
249          id: stored.id,
250          type: stored.type,
251          content,
252          mediaType: stored.mediaType,
253          filename: stored.filename,
254        }
255      }
256    }
257  
258    // Content not available
259    return null
260  }
261  
262  /**
263   * Convert LogEntry to HistoryEntry by resolving paste store references.
264   */
265  async function logEntryToHistoryEntry(entry: LogEntry): Promise<HistoryEntry> {
266    const pastedContents: Record<number, PastedContent> = {}
267  
268    for (const [id, stored] of Object.entries(entry.pastedContents || {})) {
269      const resolved = await resolveStoredPastedContent(stored)
270      if (resolved) {
271        pastedContents[Number(id)] = resolved
272      }
273    }
274  
275    return {
276      display: entry.display,
277      pastedContents,
278    }
279  }
280  
281  let pendingEntries: LogEntry[] = []
282  let isWriting = false
283  let currentFlushPromise: Promise<void> | null = null
284  let cleanupRegistered = false
285  let lastAddedEntry: LogEntry | null = null
286  // Timestamps of entries already flushed to disk that should be skipped when
287  // reading. Used by removeLastFromHistory when the entry has raced past the
288  // pending buffer. Session-scoped (module state resets on process restart).
289  const skippedTimestamps = new Set<number>()
290  
291  // Core flush logic - writes pending entries to disk
292  async function immediateFlushHistory(): Promise<void> {
293    if (pendingEntries.length === 0) {
294      return
295    }
296  
297    let release
298    try {
299      const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl')
300  
301      // Ensure the file exists before acquiring lock (append mode creates if missing)
302      await writeFile(historyPath, '', {
303        encoding: 'utf8',
304        mode: 0o600,
305        flag: 'a',
306      })
307  
308      release = await lock(historyPath, {
309        stale: 10000,
310        retries: {
311          retries: 3,
312          minTimeout: 50,
313        },
314      })
315  
316      const jsonLines = pendingEntries.map(entry => jsonStringify(entry) + '\n')
317      pendingEntries = []
318  
319      await appendFile(historyPath, jsonLines.join(''), { mode: 0o600 })
320    } catch (error) {
321      logForDebugging(`Failed to write prompt history: ${error}`)
322    } finally {
323      if (release) {
324        await release()
325      }
326    }
327  }
328  
329  async function flushPromptHistory(retries: number): Promise<void> {
330    if (isWriting || pendingEntries.length === 0) {
331      return
332    }
333  
334    // Stop trying to flush history until the next user prompt
335    if (retries > 5) {
336      return
337    }
338  
339    isWriting = true
340  
341    try {
342      await immediateFlushHistory()
343    } finally {
344      isWriting = false
345  
346      if (pendingEntries.length > 0) {
347        // Avoid trying again in a hot loop
348        await sleep(500)
349  
350        void flushPromptHistory(retries + 1)
351      }
352    }
353  }
354  
355  async function addToPromptHistory(
356    command: HistoryEntry | string,
357  ): Promise<void> {
358    const entry =
359      typeof command === 'string'
360        ? { display: command, pastedContents: {} }
361        : command
362  
363    const storedPastedContents: Record<number, StoredPastedContent> = {}
364    if (entry.pastedContents) {
365      for (const [id, content] of Object.entries(entry.pastedContents)) {
366        // Filter out images (they're stored separately in image-cache)
367        if (content.type === 'image') {
368          continue
369        }
370  
371        // For small text content, store inline
372        if (content.content.length <= MAX_PASTED_CONTENT_LENGTH) {
373          storedPastedContents[Number(id)] = {
374            id: content.id,
375            type: content.type,
376            content: content.content,
377            mediaType: content.mediaType,
378            filename: content.filename,
379          }
380        } else {
381          // For large text content, compute hash synchronously and store reference
382          // The actual disk write happens async (fire-and-forget)
383          const hash = hashPastedText(content.content)
384          storedPastedContents[Number(id)] = {
385            id: content.id,
386            type: content.type,
387            contentHash: hash,
388            mediaType: content.mediaType,
389            filename: content.filename,
390          }
391          // Fire-and-forget disk write - don't block history entry creation
392          void storePastedText(hash, content.content)
393        }
394      }
395    }
396  
397    const logEntry: LogEntry = {
398      ...entry,
399      pastedContents: storedPastedContents,
400      timestamp: Date.now(),
401      project: getProjectRoot(),
402      sessionId: getSessionId(),
403    }
404  
405    pendingEntries.push(logEntry)
406    lastAddedEntry = logEntry
407    currentFlushPromise = flushPromptHistory(0)
408    void currentFlushPromise
409  }
410  
411  export function addToHistory(command: HistoryEntry | string): void {
412    // Skip history when running in a tmux session spawned by Claude Code's Tungsten tool.
413    // This prevents verification/test sessions from polluting the user's real command history.
414    if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY)) {
415      return
416    }
417  
418    // Register cleanup on first use
419    if (!cleanupRegistered) {
420      cleanupRegistered = true
421      registerCleanup(async () => {
422        // If there's an in-progress flush, wait for it
423        if (currentFlushPromise) {
424          await currentFlushPromise
425        }
426        // If there are still pending entries after the flush completed, do one final flush
427        if (pendingEntries.length > 0) {
428          await immediateFlushHistory()
429        }
430      })
431    }
432  
433    void addToPromptHistory(command)
434  }
435  
436  export function clearPendingHistoryEntries(): void {
437    pendingEntries = []
438    lastAddedEntry = null
439    skippedTimestamps.clear()
440  }
441  
442  /**
443   * Undo the most recent addToHistory call. Used by auto-restore-on-interrupt:
444   * when Esc rewinds the conversation before any response arrives, the submit is
445   * semantically undone — the history entry should be too, otherwise Up-arrow
446   * shows the restored text twice (once from the input box, once from disk).
447   *
448   * Fast path pops from the pending buffer. If the async flush already won the
449   * race (TTFT is typically >> disk write latency), the entry's timestamp is
450   * added to a skip-set consulted by getHistory. One-shot: clears the tracked
451   * entry so a second call is a no-op.
452   */
453  export function removeLastFromHistory(): void {
454    if (!lastAddedEntry) return
455    const entry = lastAddedEntry
456    lastAddedEntry = null
457  
458    const idx = pendingEntries.lastIndexOf(entry)
459    if (idx !== -1) {
460      pendingEntries.splice(idx, 1)
461    } else {
462      skippedTimestamps.add(entry.timestamp)
463    }
464  }