/ utils / contextSuggestions.ts
contextSuggestions.ts
  1  import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
  2  import { FILE_READ_TOOL_NAME } from '../tools/FileReadTool/prompt.js'
  3  import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js'
  4  import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'
  5  import type { ContextData } from './analyzeContext.js'
  6  import { getDisplayPath } from './file.js'
  7  import { formatTokens } from './format.js'
  8  
  9  // --
 10  
 11  export type SuggestionSeverity = 'info' | 'warning'
 12  
 13  export type ContextSuggestion = {
 14    severity: SuggestionSeverity
 15    title: string
 16    detail: string
 17    /** Estimated tokens that could be saved */
 18    savingsTokens?: number
 19  }
 20  
 21  // Thresholds for triggering suggestions
 22  const LARGE_TOOL_RESULT_PERCENT = 15 // tool results > 15% of context
 23  const LARGE_TOOL_RESULT_TOKENS = 10_000
 24  const READ_BLOAT_PERCENT = 5 // Read results > 5% of context
 25  const NEAR_CAPACITY_PERCENT = 80
 26  const MEMORY_HIGH_PERCENT = 5
 27  const MEMORY_HIGH_TOKENS = 5_000
 28  
 29  // --
 30  
 31  export function generateContextSuggestions(
 32    data: ContextData,
 33  ): ContextSuggestion[] {
 34    const suggestions: ContextSuggestion[] = []
 35  
 36    checkNearCapacity(data, suggestions)
 37    checkLargeToolResults(data, suggestions)
 38    checkReadResultBloat(data, suggestions)
 39    checkMemoryBloat(data, suggestions)
 40    checkAutoCompactDisabled(data, suggestions)
 41  
 42    // Sort: warnings first, then by savings descending
 43    suggestions.sort((a, b) => {
 44      if (a.severity !== b.severity) {
 45        return a.severity === 'warning' ? -1 : 1
 46      }
 47      return (b.savingsTokens ?? 0) - (a.savingsTokens ?? 0)
 48    })
 49  
 50    return suggestions
 51  }
 52  
 53  // --
 54  
 55  function checkNearCapacity(
 56    data: ContextData,
 57    suggestions: ContextSuggestion[],
 58  ): void {
 59    if (data.percentage >= NEAR_CAPACITY_PERCENT) {
 60      suggestions.push({
 61        severity: 'warning',
 62        title: `Context is ${data.percentage}% full`,
 63        detail: data.isAutoCompactEnabled
 64          ? 'Autocompact will trigger soon, which discards older messages. Use /compact now to control what gets kept.'
 65          : 'Autocompact is disabled. Use /compact to free space, or enable autocompact in /config.',
 66      })
 67    }
 68  }
 69  
 70  function checkLargeToolResults(
 71    data: ContextData,
 72    suggestions: ContextSuggestion[],
 73  ): void {
 74    if (!data.messageBreakdown) return
 75  
 76    for (const tool of data.messageBreakdown.toolCallsByType) {
 77      const totalToolTokens = tool.callTokens + tool.resultTokens
 78      const percent = (totalToolTokens / data.rawMaxTokens) * 100
 79  
 80      if (
 81        percent < LARGE_TOOL_RESULT_PERCENT ||
 82        totalToolTokens < LARGE_TOOL_RESULT_TOKENS
 83      ) {
 84        continue
 85      }
 86  
 87      const suggestion = getLargeToolSuggestion(
 88        tool.name,
 89        totalToolTokens,
 90        percent,
 91      )
 92      if (suggestion) {
 93        suggestions.push(suggestion)
 94      }
 95    }
 96  }
 97  
 98  function getLargeToolSuggestion(
 99    toolName: string,
100    tokens: number,
101    percent: number,
102  ): ContextSuggestion | null {
103    const tokenStr = formatTokens(tokens)
104  
105    switch (toolName) {
106      case BASH_TOOL_NAME:
107        return {
108          severity: 'warning',
109          title: `Bash results using ${tokenStr} tokens (${percent.toFixed(0)}%)`,
110          detail:
111            'Pipe output through head, tail, or grep to reduce result size. Avoid cat on large files \u2014 use Read with offset/limit instead.',
112          savingsTokens: Math.floor(tokens * 0.5),
113        }
114      case FILE_READ_TOOL_NAME:
115        return {
116          severity: 'info',
117          title: `Read results using ${tokenStr} tokens (${percent.toFixed(0)}%)`,
118          detail:
119            'Use offset and limit parameters to read only the sections you need. Avoid re-reading entire files when you only need a few lines.',
120          savingsTokens: Math.floor(tokens * 0.3),
121        }
122      case GREP_TOOL_NAME:
123        return {
124          severity: 'info',
125          title: `Grep results using ${tokenStr} tokens (${percent.toFixed(0)}%)`,
126          detail:
127            'Add more specific patterns or use the glob or type parameter to narrow file types. Consider Glob for file discovery instead of Grep.',
128          savingsTokens: Math.floor(tokens * 0.3),
129        }
130      case WEB_FETCH_TOOL_NAME:
131        return {
132          severity: 'info',
133          title: `WebFetch results using ${tokenStr} tokens (${percent.toFixed(0)}%)`,
134          detail:
135            'Web page content can be very large. Consider extracting only the specific information needed.',
136          savingsTokens: Math.floor(tokens * 0.4),
137        }
138      default:
139        if (percent >= 20) {
140          return {
141            severity: 'info',
142            title: `${toolName} using ${tokenStr} tokens (${percent.toFixed(0)}%)`,
143            detail: `This tool is consuming a significant portion of context.`,
144            savingsTokens: Math.floor(tokens * 0.2),
145          }
146        }
147        return null
148    }
149  }
150  
151  function checkReadResultBloat(
152    data: ContextData,
153    suggestions: ContextSuggestion[],
154  ): void {
155    if (!data.messageBreakdown) return
156  
157    const callsByType = data.messageBreakdown.toolCallsByType
158    const readTool = callsByType.find(t => t.name === FILE_READ_TOOL_NAME)
159    if (!readTool) return
160  
161    const totalReadTokens = readTool.callTokens + readTool.resultTokens
162    const totalReadPercent = (totalReadTokens / data.rawMaxTokens) * 100
163    const readPercent = (readTool.resultTokens / data.rawMaxTokens) * 100
164  
165    // Skip if already covered by checkLargeToolResults (>= 15% band)
166    if (
167      totalReadPercent >= LARGE_TOOL_RESULT_PERCENT &&
168      totalReadTokens >= LARGE_TOOL_RESULT_TOKENS
169    ) {
170      return
171    }
172  
173    if (
174      readPercent >= READ_BLOAT_PERCENT &&
175      readTool.resultTokens >= LARGE_TOOL_RESULT_TOKENS
176    ) {
177      suggestions.push({
178        severity: 'info',
179        title: `File reads using ${formatTokens(readTool.resultTokens)} tokens (${readPercent.toFixed(0)}%)`,
180        detail:
181          'If you are re-reading files, consider referencing earlier reads. Use offset/limit for large files.',
182        savingsTokens: Math.floor(readTool.resultTokens * 0.3),
183      })
184    }
185  }
186  
187  function checkMemoryBloat(
188    data: ContextData,
189    suggestions: ContextSuggestion[],
190  ): void {
191    const totalMemoryTokens = data.memoryFiles.reduce(
192      (sum, f) => sum + f.tokens,
193      0,
194    )
195    const memoryPercent = (totalMemoryTokens / data.rawMaxTokens) * 100
196  
197    if (
198      memoryPercent >= MEMORY_HIGH_PERCENT &&
199      totalMemoryTokens >= MEMORY_HIGH_TOKENS
200    ) {
201      const largestFiles = [...data.memoryFiles]
202        .sort((a, b) => b.tokens - a.tokens)
203        .slice(0, 3)
204        .map(f => {
205          const name = getDisplayPath(f.path)
206          return `${name} (${formatTokens(f.tokens)})`
207        })
208        .join(', ')
209  
210      suggestions.push({
211        severity: 'info',
212        title: `Memory files using ${formatTokens(totalMemoryTokens)} tokens (${memoryPercent.toFixed(0)}%)`,
213        detail: `Largest: ${largestFiles}. Use /memory to review and prune stale entries.`,
214        savingsTokens: Math.floor(totalMemoryTokens * 0.3),
215      })
216    }
217  }
218  
219  function checkAutoCompactDisabled(
220    data: ContextData,
221    suggestions: ContextSuggestion[],
222  ): void {
223    if (
224      !data.isAutoCompactEnabled &&
225      data.percentage >= 50 &&
226      data.percentage < NEAR_CAPACITY_PERCENT
227    ) {
228      suggestions.push({
229        severity: 'info',
230        title: 'Autocompact is disabled',
231        detail:
232          'Without autocompact, you will hit context limits and lose the conversation. Enable it in /config or use /compact manually.',
233      })
234    }
235  }