source dump of claude code
at main 241 lines 8.2 kB view raw
1import { memoize } from 'lodash-es' 2import type { Command } from 'src/commands.js' 3import { 4 getCommandName, 5 getSkillToolCommands, 6 getSlashCommandToolSkills, 7} from 'src/commands.js' 8import { COMMAND_NAME_TAG } from '../../constants/xml.js' 9import { stringWidth } from '../../ink/stringWidth.js' 10import { 11 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 12 logEvent, 13} from '../../services/analytics/index.js' 14import { count } from '../../utils/array.js' 15import { logForDebugging } from '../../utils/debug.js' 16import { toError } from '../../utils/errors.js' 17import { truncate } from '../../utils/format.js' 18import { logError } from '../../utils/log.js' 19 20// Skill listing gets 1% of the context window (in characters) 21export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 22export const CHARS_PER_TOKEN = 4 23export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4 24 25// Per-entry hard cap. The listing is for discovery only — the Skill tool loads 26// full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation 27// tokens without improving match rate. Applies to all entries, including bundled, 28// since the cap is generous enough to preserve the core use case. 29export const MAX_LISTING_DESC_CHARS = 250 30 31export function getCharBudget(contextWindowTokens?: number): number { 32 if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) { 33 return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET) 34 } 35 if (contextWindowTokens) { 36 return Math.floor( 37 contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT, 38 ) 39 } 40 return DEFAULT_CHAR_BUDGET 41} 42 43function getCommandDescription(cmd: Command): string { 44 const desc = cmd.whenToUse 45 ? `${cmd.description} - ${cmd.whenToUse}` 46 : cmd.description 47 return desc.length > MAX_LISTING_DESC_CHARS 48 ? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026' 49 : desc 50} 51 52function formatCommandDescription(cmd: Command): string { 53 // Debug: log if userFacingName differs from cmd.name for plugin skills 54 const displayName = getCommandName(cmd) 55 if ( 56 cmd.name !== displayName && 57 cmd.type === 'prompt' && 58 cmd.source === 'plugin' 59 ) { 60 logForDebugging( 61 `Skill prompt: showing "${cmd.name}" (userFacingName="${displayName}")`, 62 ) 63 } 64 65 return `- ${cmd.name}: ${getCommandDescription(cmd)}` 66} 67 68const MIN_DESC_LENGTH = 20 69 70export function formatCommandsWithinBudget( 71 commands: Command[], 72 contextWindowTokens?: number, 73): string { 74 if (commands.length === 0) return '' 75 76 const budget = getCharBudget(contextWindowTokens) 77 78 // Try full descriptions first 79 const fullEntries = commands.map(cmd => ({ 80 cmd, 81 full: formatCommandDescription(cmd), 82 })) 83 // join('\n') produces N-1 newlines for N entries 84 const fullTotal = 85 fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) + 86 (fullEntries.length - 1) 87 88 if (fullTotal <= budget) { 89 return fullEntries.map(e => e.full).join('\n') 90 } 91 92 // Partition into bundled (never truncated) and rest 93 const bundledIndices = new Set<number>() 94 const restCommands: Command[] = [] 95 for (let i = 0; i < commands.length; i++) { 96 const cmd = commands[i]! 97 if (cmd.type === 'prompt' && cmd.source === 'bundled') { 98 bundledIndices.add(i) 99 } else { 100 restCommands.push(cmd) 101 } 102 } 103 104 // Compute space used by bundled skills (full descriptions, always preserved) 105 const bundledChars = fullEntries.reduce( 106 (sum, e, i) => 107 bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum, 108 0, 109 ) 110 const remainingBudget = budget - bundledChars 111 112 // Calculate max description length for non-bundled commands 113 if (restCommands.length === 0) { 114 return fullEntries.map(e => e.full).join('\n') 115 } 116 117 const restNameOverhead = 118 restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4, 0) + 119 (restCommands.length - 1) 120 const availableForDescs = remainingBudget - restNameOverhead 121 const maxDescLen = Math.floor(availableForDescs / restCommands.length) 122 123 if (maxDescLen < MIN_DESC_LENGTH) { 124 // Extreme case: non-bundled go names-only, bundled keep descriptions 125 if (process.env.USER_TYPE === 'ant') { 126 logEvent('tengu_skill_descriptions_truncated', { 127 skill_count: commands.length, 128 budget, 129 full_total: fullTotal, 130 truncation_mode: 131 'names_only' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 132 max_desc_length: maxDescLen, 133 bundled_count: bundledIndices.size, 134 bundled_chars: bundledChars, 135 }) 136 } 137 return commands 138 .map((cmd, i) => 139 bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`, 140 ) 141 .join('\n') 142 } 143 144 // Truncate non-bundled descriptions to fit within budget 145 const truncatedCount = count( 146 restCommands, 147 cmd => stringWidth(getCommandDescription(cmd)) > maxDescLen, 148 ) 149 if (process.env.USER_TYPE === 'ant') { 150 logEvent('tengu_skill_descriptions_truncated', { 151 skill_count: commands.length, 152 budget, 153 full_total: fullTotal, 154 truncation_mode: 155 'description_trimmed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 156 max_desc_length: maxDescLen, 157 truncated_count: truncatedCount, 158 // Count of bundled skills included in this prompt (excludes skills with disableModelInvocation) 159 bundled_count: bundledIndices.size, 160 bundled_chars: bundledChars, 161 }) 162 } 163 return commands 164 .map((cmd, i) => { 165 // Bundled skills always get full descriptions 166 if (bundledIndices.has(i)) return fullEntries[i]!.full 167 const description = getCommandDescription(cmd) 168 return `- ${cmd.name}: ${truncate(description, maxDescLen)}` 169 }) 170 .join('\n') 171} 172 173export const getPrompt = memoize(async (_cwd: string): Promise<string> => { 174 return `Execute a skill within the main conversation 175 176When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge. 177 178When users reference a "slash command" or "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke it. 179 180How to invoke: 181- Use this tool with the skill name and optional arguments 182- Examples: 183 - \`skill: "pdf"\` - invoke the pdf skill 184 - \`skill: "commit", args: "-m 'Fix bug'"\` - invoke with arguments 185 - \`skill: "review-pr", args: "123"\` - invoke with arguments 186 - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name 187 188Important: 189- Available skills are listed in system-reminder messages in the conversation 190- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task 191- NEVER mention a skill without actually calling this tool 192- Do not invoke a skill that is already running 193- Do not use this tool for built-in CLI commands (like /help, /clear, etc.) 194- If you see a <${COMMAND_NAME_TAG}> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again 195` 196}) 197 198export async function getSkillToolInfo(cwd: string): Promise<{ 199 totalCommands: number 200 includedCommands: number 201}> { 202 const agentCommands = await getSkillToolCommands(cwd) 203 204 return { 205 totalCommands: agentCommands.length, 206 includedCommands: agentCommands.length, 207 } 208} 209 210// Returns the commands included in the SkillTool prompt. 211// All commands are always included (descriptions may be truncated to fit budget). 212// Used by analyzeContext to count skill tokens. 213export function getLimitedSkillToolCommands(cwd: string): Promise<Command[]> { 214 return getSkillToolCommands(cwd) 215} 216 217export function clearPromptCache(): void { 218 getPrompt.cache?.clear?.() 219} 220 221export async function getSkillInfo(cwd: string): Promise<{ 222 totalSkills: number 223 includedSkills: number 224}> { 225 try { 226 const skills = await getSlashCommandToolSkills(cwd) 227 228 return { 229 totalSkills: skills.length, 230 includedSkills: skills.length, 231 } 232 } catch (error) { 233 logError(toError(error)) 234 235 // Return zeros rather than throwing - let caller decide how to handle 236 return { 237 totalSkills: 0, 238 includedSkills: 0, 239 } 240 } 241}