forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprompt.ts
More file actions
241 lines (212 loc) · 8.03 KB
/
Copy pathprompt.ts
File metadata and controls
241 lines (212 loc) · 8.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
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'
// Skill listing gets 1% of the context window (in characters)
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
// Per-entry hard cap. The listing is for discovery only — the Skill tool loads
// full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation
// tokens without improving match rate. Applies to all entries, including bundled,
// since the cap is generous enough to preserve the core use case.
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 {
// Debug: log if userFacingName differs from cmd.name for plugin skills
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)
// Try full descriptions first
const fullEntries = commands.map(cmd => ({
cmd,
full: formatCommandDescription(cmd),
}))
// join('\n') produces N-1 newlines for N entries
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')
}
// Partition into bundled (never truncated) and rest
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)
}
}
// Compute space used by bundled skills (full descriptions, always preserved)
const bundledChars = fullEntries.reduce(
(sum, e, i) =>
bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum,
0,
)
const remainingBudget = budget - bundledChars
// Calculate max description length for non-bundled commands
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) {
// Extreme case: non-bundled go names-only, bundled keep descriptions
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')
}
// Truncate non-bundled descriptions to fit within budget
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,
// Count of bundled skills included in this prompt (excludes skills with disableModelInvocation)
bundled_count: bundledIndices.size,
bundled_chars: bundledChars,
})
}
return commands
.map((cmd, i) => {
// Bundled skills always get full descriptions
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,
}
}
// Returns the commands included in the SkillTool prompt.
// All commands are always included (descriptions may be truncated to fit budget).
// Used by analyzeContext to count skill tokens.
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 zeros rather than throwing - let caller decide how to handle
return {
totalSkills: 0,
includedSkills: 0,
}
}
}