/ utils / doctorContextWarnings.ts
doctorContextWarnings.ts
  1  import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
  2  import type { Tool, ToolPermissionContext } from '../Tool.js'
  3  import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'
  4  import { countMcpToolTokens } from './analyzeContext.js'
  5  import {
  6    getLargeMemoryFiles,
  7    getMemoryFiles,
  8    MAX_MEMORY_CHARACTER_COUNT,
  9  } from './claudemd.js'
 10  import { getMainLoopModel } from './model/model.js'
 11  import { permissionRuleValueToString } from './permissions/permissionRuleParser.js'
 12  import { detectUnreachableRules } from './permissions/shadowedRuleDetection.js'
 13  import { SandboxManager } from './sandbox/sandbox-adapter.js'
 14  import {
 15    AGENT_DESCRIPTIONS_THRESHOLD,
 16    getAgentDescriptionsTotalTokens,
 17  } from './statusNoticeHelpers.js'
 18  import { plural } from './stringUtils.js'
 19  
 20  // Thresholds (matching status notices and existing patterns)
 21  const MCP_TOOLS_THRESHOLD = 25_000 // 15k tokens
 22  
 23  export type ContextWarning = {
 24    type:
 25      | 'claudemd_files'
 26      | 'agent_descriptions'
 27      | 'mcp_tools'
 28      | 'unreachable_rules'
 29    severity: 'warning' | 'error'
 30    message: string
 31    details: string[]
 32    currentValue: number
 33    threshold: number
 34  }
 35  
 36  export type ContextWarnings = {
 37    claudeMdWarning: ContextWarning | null
 38    agentWarning: ContextWarning | null
 39    mcpWarning: ContextWarning | null
 40    unreachableRulesWarning: ContextWarning | null
 41  }
 42  
 43  async function checkClaudeMdFiles(): Promise<ContextWarning | null> {
 44    const largeFiles = getLargeMemoryFiles(await getMemoryFiles())
 45  
 46    // This already filters for files > 40k chars each
 47    if (largeFiles.length === 0) {
 48      return null
 49    }
 50  
 51    const details = largeFiles
 52      .sort((a, b) => b.content.length - a.content.length)
 53      .map(file => `${file.path}: ${file.content.length.toLocaleString()} chars`)
 54  
 55    const message =
 56      largeFiles.length === 1
 57        ? `Large CLAUDE.md file detected (${largeFiles[0]!.content.length.toLocaleString()} chars > ${MAX_MEMORY_CHARACTER_COUNT.toLocaleString()})`
 58        : `${largeFiles.length} large CLAUDE.md files detected (each > ${MAX_MEMORY_CHARACTER_COUNT.toLocaleString()} chars)`
 59  
 60    return {
 61      type: 'claudemd_files',
 62      severity: 'warning',
 63      message,
 64      details,
 65      currentValue: largeFiles.length, // Number of files exceeding threshold
 66      threshold: MAX_MEMORY_CHARACTER_COUNT,
 67    }
 68  }
 69  
 70  /**
 71   * Check agent descriptions token count
 72   */
 73  async function checkAgentDescriptions(
 74    agentInfo: AgentDefinitionsResult | null,
 75  ): Promise<ContextWarning | null> {
 76    if (!agentInfo) {
 77      return null
 78    }
 79  
 80    const totalTokens = getAgentDescriptionsTotalTokens(agentInfo)
 81  
 82    if (totalTokens <= AGENT_DESCRIPTIONS_THRESHOLD) {
 83      return null
 84    }
 85  
 86    // Calculate tokens for each agent
 87    const agentTokens = agentInfo.activeAgents
 88      .filter(a => a.source !== 'built-in')
 89      .map(agent => {
 90        const description = `${agent.agentType}: ${agent.whenToUse}`
 91        return {
 92          name: agent.agentType,
 93          tokens: roughTokenCountEstimation(description),
 94        }
 95      })
 96      .sort((a, b) => b.tokens - a.tokens)
 97  
 98    const details = agentTokens
 99      .slice(0, 5)
100      .map(agent => `${agent.name}: ~${agent.tokens.toLocaleString()} tokens`)
101  
102    if (agentTokens.length > 5) {
103      details.push(`(${agentTokens.length - 5} more custom agents)`)
104    }
105  
106    return {
107      type: 'agent_descriptions',
108      severity: 'warning',
109      message: `Large agent descriptions (~${totalTokens.toLocaleString()} tokens > ${AGENT_DESCRIPTIONS_THRESHOLD.toLocaleString()})`,
110      details,
111      currentValue: totalTokens,
112      threshold: AGENT_DESCRIPTIONS_THRESHOLD,
113    }
114  }
115  
116  /**
117   * Check MCP tools token count
118   */
119  async function checkMcpTools(
120    tools: Tool[],
121    getToolPermissionContext: () => Promise<ToolPermissionContext>,
122    agentInfo: AgentDefinitionsResult | null,
123  ): Promise<ContextWarning | null> {
124    const mcpTools = tools.filter(tool => tool.isMcp)
125  
126    // Note: MCP tools are loaded asynchronously and may not be available
127    // when doctor command runs, as it executes before MCP connections are established
128    if (mcpTools.length === 0) {
129      return null
130    }
131  
132    try {
133      // Use the existing countMcpToolTokens function from analyzeContext
134      const model = getMainLoopModel()
135      const { mcpToolTokens, mcpToolDetails } = await countMcpToolTokens(
136        tools,
137        getToolPermissionContext,
138        agentInfo,
139        model,
140      )
141  
142      if (mcpToolTokens <= MCP_TOOLS_THRESHOLD) {
143        return null
144      }
145  
146      // Group tools by server
147      const toolsByServer = new Map<string, { count: number; tokens: number }>()
148  
149      for (const tool of mcpToolDetails) {
150        // Extract server name from tool name (format: mcp__servername__toolname)
151        const parts = tool.name.split('__')
152        const serverName = parts[1] || 'unknown'
153  
154        const current = toolsByServer.get(serverName) || { count: 0, tokens: 0 }
155        toolsByServer.set(serverName, {
156          count: current.count + 1,
157          tokens: current.tokens + tool.tokens,
158        })
159      }
160  
161      // Sort servers by token count
162      const sortedServers = Array.from(toolsByServer.entries()).sort(
163        (a, b) => b[1].tokens - a[1].tokens,
164      )
165  
166      const details = sortedServers
167        .slice(0, 5)
168        .map(
169          ([name, info]) =>
170            `${name}: ${info.count} tools (~${info.tokens.toLocaleString()} tokens)`,
171        )
172  
173      if (sortedServers.length > 5) {
174        details.push(`(${sortedServers.length - 5} more servers)`)
175      }
176  
177      return {
178        type: 'mcp_tools',
179        severity: 'warning',
180        message: `Large MCP tools context (~${mcpToolTokens.toLocaleString()} tokens > ${MCP_TOOLS_THRESHOLD.toLocaleString()})`,
181        details,
182        currentValue: mcpToolTokens,
183        threshold: MCP_TOOLS_THRESHOLD,
184      }
185    } catch (_error) {
186      // If token counting fails, fall back to character-based estimation
187      const estimatedTokens = mcpTools.reduce((total, tool) => {
188        const chars = (tool.name?.length || 0) + tool.description.length
189        return total + roughTokenCountEstimation(chars.toString())
190      }, 0)
191  
192      if (estimatedTokens <= MCP_TOOLS_THRESHOLD) {
193        return null
194      }
195  
196      return {
197        type: 'mcp_tools',
198        severity: 'warning',
199        message: `Large MCP tools context (~${estimatedTokens.toLocaleString()} tokens estimated > ${MCP_TOOLS_THRESHOLD.toLocaleString()})`,
200        details: [
201          `${mcpTools.length} MCP tools detected (token count estimated)`,
202        ],
203        currentValue: estimatedTokens,
204        threshold: MCP_TOOLS_THRESHOLD,
205      }
206    }
207  }
208  
209  /**
210   * Check for unreachable permission rules (e.g., specific allow rules shadowed by tool-wide ask rules)
211   */
212  async function checkUnreachableRules(
213    getToolPermissionContext: () => Promise<ToolPermissionContext>,
214  ): Promise<ContextWarning | null> {
215    const context = await getToolPermissionContext()
216    const sandboxAutoAllowEnabled =
217      SandboxManager.isSandboxingEnabled() &&
218      SandboxManager.isAutoAllowBashIfSandboxedEnabled()
219  
220    const unreachable = detectUnreachableRules(context, {
221      sandboxAutoAllowEnabled,
222    })
223  
224    if (unreachable.length === 0) {
225      return null
226    }
227  
228    const details = unreachable.flatMap(r => [
229      `${permissionRuleValueToString(r.rule.ruleValue)}: ${r.reason}`,
230      `  Fix: ${r.fix}`,
231    ])
232  
233    return {
234      type: 'unreachable_rules',
235      severity: 'warning',
236      message: `${unreachable.length} ${plural(unreachable.length, 'unreachable permission rule')} detected`,
237      details,
238      currentValue: unreachable.length,
239      threshold: 0,
240    }
241  }
242  
243  /**
244   * Check all context warnings for the doctor command
245   */
246  export async function checkContextWarnings(
247    tools: Tool[],
248    agentInfo: AgentDefinitionsResult | null,
249    getToolPermissionContext: () => Promise<ToolPermissionContext>,
250  ): Promise<ContextWarnings> {
251    const [claudeMdWarning, agentWarning, mcpWarning, unreachableRulesWarning] =
252      await Promise.all([
253        checkClaudeMdFiles(),
254        checkAgentDescriptions(agentInfo),
255        checkMcpTools(tools, getToolPermissionContext, agentInfo),
256        checkUnreachableRules(getToolPermissionContext),
257      ])
258  
259    return {
260      claudeMdWarning,
261      agentWarning,
262      mcpWarning,
263      unreachableRulesWarning,
264    }
265  }