/ context.ts
context.ts
  1  import { feature } from 'bun:bundle'
  2  import memoize from 'lodash-es/memoize.js'
  3  import {
  4    getAdditionalDirectoriesForClaudeMd,
  5    setCachedClaudeMdContent,
  6  } from './bootstrap/state.js'
  7  import { getLocalISODate } from './constants/common.js'
  8  import {
  9    filterInjectedMemoryFiles,
 10    getClaudeMds,
 11    getMemoryFiles,
 12  } from './utils/claudemd.js'
 13  import { logForDiagnosticsNoPII } from './utils/diagLogs.js'
 14  import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
 15  import { execFileNoThrow } from './utils/execFileNoThrow.js'
 16  import { getBranch, getDefaultBranch, getIsGit, gitExe } from './utils/git.js'
 17  import { shouldIncludeGitInstructions } from './utils/gitSettings.js'
 18  import { logError } from './utils/log.js'
 19  
 20  const MAX_STATUS_CHARS = 2000
 21  
 22  // System prompt injection for cache breaking (ant-only, ephemeral debugging state)
 23  let systemPromptInjection: string | null = null
 24  
 25  export function getSystemPromptInjection(): string | null {
 26    return systemPromptInjection
 27  }
 28  
 29  export function setSystemPromptInjection(value: string | null): void {
 30    systemPromptInjection = value
 31    // Clear context caches immediately when injection changes
 32    getUserContext.cache.clear?.()
 33    getSystemContext.cache.clear?.()
 34  }
 35  
 36  export const getGitStatus = memoize(async (): Promise<string | null> => {
 37    if (process.env.NODE_ENV === 'test') {
 38      // Avoid cycles in tests
 39      return null
 40    }
 41  
 42    const startTime = Date.now()
 43    logForDiagnosticsNoPII('info', 'git_status_started')
 44  
 45    const isGitStart = Date.now()
 46    const isGit = await getIsGit()
 47    logForDiagnosticsNoPII('info', 'git_is_git_check_completed', {
 48      duration_ms: Date.now() - isGitStart,
 49      is_git: isGit,
 50    })
 51  
 52    if (!isGit) {
 53      logForDiagnosticsNoPII('info', 'git_status_skipped_not_git', {
 54        duration_ms: Date.now() - startTime,
 55      })
 56      return null
 57    }
 58  
 59    try {
 60      const gitCmdsStart = Date.now()
 61      const [branch, mainBranch, status, log, userName] = await Promise.all([
 62        getBranch(),
 63        getDefaultBranch(),
 64        execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], {
 65          preserveOutputOnError: false,
 66        }).then(({ stdout }) => stdout.trim()),
 67        execFileNoThrow(
 68          gitExe(),
 69          ['--no-optional-locks', 'log', '--oneline', '-n', '5'],
 70          {
 71            preserveOutputOnError: false,
 72          },
 73        ).then(({ stdout }) => stdout.trim()),
 74        execFileNoThrow(gitExe(), ['config', 'user.name'], {
 75          preserveOutputOnError: false,
 76        }).then(({ stdout }) => stdout.trim()),
 77      ])
 78  
 79      logForDiagnosticsNoPII('info', 'git_commands_completed', {
 80        duration_ms: Date.now() - gitCmdsStart,
 81        status_length: status.length,
 82      })
 83  
 84      // Check if status exceeds character limit
 85      const truncatedStatus =
 86        status.length > MAX_STATUS_CHARS
 87          ? status.substring(0, MAX_STATUS_CHARS) +
 88            '\n... (truncated because it exceeds 2k characters. If you need more information, run "git status" using BashTool)'
 89          : status
 90  
 91      logForDiagnosticsNoPII('info', 'git_status_completed', {
 92        duration_ms: Date.now() - startTime,
 93        truncated: status.length > MAX_STATUS_CHARS,
 94      })
 95  
 96      return [
 97        `This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.`,
 98        `Current branch: ${branch}`,
 99        `Main branch (you will usually use this for PRs): ${mainBranch}`,
100        ...(userName ? [`Git user: ${userName}`] : []),
101        `Status:\n${truncatedStatus || '(clean)'}`,
102        `Recent commits:\n${log}`,
103      ].join('\n\n')
104    } catch (error) {
105      logForDiagnosticsNoPII('error', 'git_status_failed', {
106        duration_ms: Date.now() - startTime,
107      })
108      logError(error)
109      return null
110    }
111  })
112  
113  /**
114   * This context is prepended to each conversation, and cached for the duration of the conversation.
115   */
116  export const getSystemContext = memoize(
117    async (): Promise<{
118      [k: string]: string
119    }> => {
120      const startTime = Date.now()
121      logForDiagnosticsNoPII('info', 'system_context_started')
122  
123      // Skip git status in CCR (unnecessary overhead on resume) or when git instructions are disabled
124      const gitStatus =
125        isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
126        !shouldIncludeGitInstructions()
127          ? null
128          : await getGitStatus()
129  
130      // Include system prompt injection if set (for cache breaking, ant-only)
131      const injection = feature('BREAK_CACHE_COMMAND')
132        ? getSystemPromptInjection()
133        : null
134  
135      logForDiagnosticsNoPII('info', 'system_context_completed', {
136        duration_ms: Date.now() - startTime,
137        has_git_status: gitStatus !== null,
138        has_injection: injection !== null,
139      })
140  
141      return {
142        ...(gitStatus && { gitStatus }),
143        ...(feature('BREAK_CACHE_COMMAND') && injection
144          ? {
145              cacheBreaker: `[CACHE_BREAKER: ${injection}]`,
146            }
147          : {}),
148      }
149    },
150  )
151  
152  /**
153   * This context is prepended to each conversation, and cached for the duration of the conversation.
154   */
155  export const getUserContext = memoize(
156    async (): Promise<{
157      [k: string]: string
158    }> => {
159      const startTime = Date.now()
160      logForDiagnosticsNoPII('info', 'user_context_started')
161  
162      // CLAUDE_CODE_DISABLE_CLAUDE_MDS: hard off, always.
163      // --bare: skip auto-discovery (cwd walk), BUT honor explicit --add-dir.
164      // --bare means "skip what I didn't ask for", not "ignore what I asked for".
165      const shouldDisableClaudeMd =
166        isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
167        (isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
168      // Await the async I/O (readFile/readdir directory walk) so the event
169      // loop yields naturally at the first fs.readFile.
170      const claudeMd = shouldDisableClaudeMd
171        ? null
172        : getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
173      // Cache for the auto-mode classifier (yoloClassifier.ts reads this
174      // instead of importing claudemd.ts directly, which would create a
175      // cycle through permissions/filesystem → permissions → yoloClassifier).
176      setCachedClaudeMdContent(claudeMd || null)
177  
178      logForDiagnosticsNoPII('info', 'user_context_completed', {
179        duration_ms: Date.now() - startTime,
180        claudemd_length: claudeMd?.length ?? 0,
181        claudemd_disabled: Boolean(shouldDisableClaudeMd),
182      })
183  
184      return {
185        ...(claudeMd && { claudeMd }),
186        currentDate: `Today's date is ${getLocalISODate()}.`,
187      }
188    },
189  )