| import { memoize } from 'lodash-es' |
| import type { Command } from 'src/commands.js' |
| import { |
| getCommandName, |
| getSkillToolCommands, |
| getSlashCommandToolSkills, |
| } from 'src/commands.js' |
| import { COMMAND_NAME_TAG } from '../../constants/xml.js' |
| import { stringWidth } from '../../ink/stringWidth.js' |
| import { |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| logEvent, |
| } from '../../services/analytics/index.js' |
| import { count } from '../../utils/array.js' |
| import { logForDebugging } from '../../utils/debug.js' |
| import { toError } from '../../utils/errors.js' |
| import { truncate } from '../../utils/format.js' |
| import { logError } from '../../utils/log.js' |
|
|
| |
| export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 |
| export const CHARS_PER_TOKEN = 4 |
| export const DEFAULT_CHAR_BUDGET = 8_000 |
|
|
| |
| |
| |
| |
| export const MAX_LISTING_DESC_CHARS = 250 |
|
|
| export function getCharBudget(contextWindowTokens?: number): number { |
| if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) { |
| return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET) |
| } |
| if (contextWindowTokens) { |
| return Math.floor( |
| contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT, |
| ) |
| } |
| return DEFAULT_CHAR_BUDGET |
| } |
|
|
| function getCommandDescription(cmd: Command): string { |
| const desc = cmd.whenToUse |
| ? `${cmd.description} - ${cmd.whenToUse}` |
| : cmd.description |
| return desc.length > MAX_LISTING_DESC_CHARS |
| ? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026' |
| : desc |
| } |
|
|
| function formatCommandDescription(cmd: Command): string { |
| |
| const displayName = getCommandName(cmd) |
| if ( |
| cmd.name !== displayName && |
| cmd.type === 'prompt' && |
| cmd.source === 'plugin' |
| ) { |
| logForDebugging( |
| `Skill prompt: showing "${cmd.name}" (userFacingName="${displayName}")`, |
| ) |
| } |
|
|
| return `- ${cmd.name}: ${getCommandDescription(cmd)}` |
| } |
|
|
| const MIN_DESC_LENGTH = 20 |
|
|
| export function formatCommandsWithinBudget( |
| commands: Command[], |
| contextWindowTokens?: number, |
| ): string { |
| if (commands.length === 0) return '' |
|
|
| const budget = getCharBudget(contextWindowTokens) |
|
|
| |
| const fullEntries = commands.map(cmd => ({ |
| cmd, |
| full: formatCommandDescription(cmd), |
| })) |
| |
| const fullTotal = |
| fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) + |
| (fullEntries.length - 1) |
|
|
| if (fullTotal <= budget) { |
| return fullEntries.map(e => e.full).join('\n') |
| } |
|
|
| |
| const bundledIndices = new Set<number>() |
| const restCommands: Command[] = [] |
| for (let i = 0; i < commands.length; i++) { |
| const cmd = commands[i]! |
| if (cmd.type === 'prompt' && cmd.source === 'bundled') { |
| bundledIndices.add(i) |
| } else { |
| restCommands.push(cmd) |
| } |
| } |
|
|
| |
| const bundledChars = fullEntries.reduce( |
| (sum, e, i) => |
| bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum, |
| 0, |
| ) |
| const remainingBudget = budget - bundledChars |
|
|
| |
| if (restCommands.length === 0) { |
| return fullEntries.map(e => e.full).join('\n') |
| } |
|
|
| const restNameOverhead = |
| restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4, 0) + |
| (restCommands.length - 1) |
| const availableForDescs = remainingBudget - restNameOverhead |
| const maxDescLen = Math.floor(availableForDescs / restCommands.length) |
|
|
| if (maxDescLen < MIN_DESC_LENGTH) { |
| |
| if (process.env.USER_TYPE === 'ant') { |
| logEvent('tengu_skill_descriptions_truncated', { |
| skill_count: commands.length, |
| budget, |
| full_total: fullTotal, |
| truncation_mode: |
| 'names_only' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| max_desc_length: maxDescLen, |
| bundled_count: bundledIndices.size, |
| bundled_chars: bundledChars, |
| }) |
| } |
| return commands |
| .map((cmd, i) => |
| bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`, |
| ) |
| .join('\n') |
| } |
|
|
| |
| const truncatedCount = count( |
| restCommands, |
| cmd => stringWidth(getCommandDescription(cmd)) > maxDescLen, |
| ) |
| if (process.env.USER_TYPE === 'ant') { |
| logEvent('tengu_skill_descriptions_truncated', { |
| skill_count: commands.length, |
| budget, |
| full_total: fullTotal, |
| truncation_mode: |
| 'description_trimmed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| max_desc_length: maxDescLen, |
| truncated_count: truncatedCount, |
| |
| bundled_count: bundledIndices.size, |
| bundled_chars: bundledChars, |
| }) |
| } |
| return commands |
| .map((cmd, i) => { |
| |
| if (bundledIndices.has(i)) return fullEntries[i]!.full |
| const description = getCommandDescription(cmd) |
| return `- ${cmd.name}: ${truncate(description, maxDescLen)}` |
| }) |
| .join('\n') |
| } |
|
|
| export const getPrompt = memoize(async (_cwd: string): Promise<string> => { |
| return `Execute a skill within the main conversation |
| |
| When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge. |
| |
| When users reference a "slash command" or "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke it. |
| |
| How to invoke: |
| - Use this tool with the skill name and optional arguments |
| - Examples: |
| - \`skill: "pdf"\` - invoke the pdf skill |
| - \`skill: "commit", args: "-m 'Fix bug'"\` - invoke with arguments |
| - \`skill: "review-pr", args: "123"\` - invoke with arguments |
| - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name |
| |
| Important: |
| - Available skills are listed in system-reminder messages in the conversation |
| - 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 |
| - NEVER mention a skill without actually calling this tool |
| - Do not invoke a skill that is already running |
| - Do not use this tool for built-in CLI commands (like /help, /clear, etc.) |
| - 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 |
| ` |
| }) |
|
|
| export async function getSkillToolInfo(cwd: string): Promise<{ |
| totalCommands: number |
| includedCommands: number |
| }> { |
| const agentCommands = await getSkillToolCommands(cwd) |
|
|
| return { |
| totalCommands: agentCommands.length, |
| includedCommands: agentCommands.length, |
| } |
| } |
|
|
| |
| |
| |
| export function getLimitedSkillToolCommands(cwd: string): Promise<Command[]> { |
| return getSkillToolCommands(cwd) |
| } |
|
|
| export function clearPromptCache(): void { |
| getPrompt.cache?.clear?.() |
| } |
|
|
| export async function getSkillInfo(cwd: string): Promise<{ |
| totalSkills: number |
| includedSkills: number |
| }> { |
| try { |
| const skills = await getSlashCommandToolSkills(cwd) |
|
|
| return { |
| totalSkills: skills.length, |
| includedSkills: skills.length, |
| } |
| } catch (error) { |
| logError(toError(error)) |
|
|
| |
| return { |
| totalSkills: 0, |
| includedSkills: 0, |
| } |
| } |
| } |
|
|