source dump of claude code
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 267 lines 8.4 kB view raw
1import { feature } from 'bun:bundle' 2import { getInvokedSkillsForAgent } from '../../bootstrap/state.js' 3import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4import { 5 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6 type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 7 logEvent, 8} from '../../services/analytics/index.js' 9import { queryModelWithoutStreaming } from '../../services/api/claude.js' 10import { getEmptyToolPermissionContext } from '../../Tool.js' 11import type { Message } from '../../types/message.js' 12import { createAbortController } from '../abortController.js' 13import { count } from '../array.js' 14import { getCwd } from '../cwd.js' 15import { toError } from '../errors.js' 16import { logError } from '../log.js' 17import { 18 createUserMessage, 19 extractTag, 20 extractTextContent, 21} from '../messages.js' 22import { getSmallFastModel } from '../model/model.js' 23import { jsonParse } from '../slowOperations.js' 24import { asSystemPrompt } from '../systemPromptType.js' 25import { 26 type ApiQueryHookConfig, 27 createApiQueryHook, 28} from './apiQueryHookHelper.js' 29import { registerPostSamplingHook } from './postSamplingHooks.js' 30 31const TURN_BATCH_SIZE = 5 32 33export type SkillUpdate = { 34 section: string 35 change: string 36 reason: string 37} 38 39function formatRecentMessages(messages: Message[]): string { 40 return messages 41 .filter(m => m.type === 'user' || m.type === 'assistant') 42 .map(m => { 43 const role = m.type === 'user' ? 'User' : 'Assistant' 44 const content = m.message.content 45 if (typeof content === 'string') 46 return `${role}: ${content.slice(0, 500)}` 47 const text = content 48 .filter( 49 (b): b is Extract<typeof b, { type: 'text' }> => b.type === 'text', 50 ) 51 .map(b => b.text) 52 .join('\n') 53 return `${role}: ${text.slice(0, 500)}` 54 }) 55 .join('\n\n') 56} 57 58function findProjectSkill() { 59 const skills = getInvokedSkillsForAgent(null) 60 for (const [, info] of skills) { 61 if (info.skillPath.startsWith('projectSettings:')) { 62 return info 63 } 64 } 65 return undefined 66} 67 68function createSkillImprovementHook() { 69 let lastAnalyzedCount = 0 70 let lastAnalyzedIndex = 0 71 72 const config: ApiQueryHookConfig<SkillUpdate[]> = { 73 name: 'skill_improvement', 74 75 async shouldRun(context) { 76 if (context.querySource !== 'repl_main_thread') { 77 return false 78 } 79 80 if (!findProjectSkill()) { 81 return false 82 } 83 84 // Only run every TURN_BATCH_SIZE user messages 85 const userCount = count(context.messages, m => m.type === 'user') 86 if (userCount - lastAnalyzedCount < TURN_BATCH_SIZE) { 87 return false 88 } 89 90 lastAnalyzedCount = userCount 91 return true 92 }, 93 94 buildMessages(context) { 95 const projectSkill = findProjectSkill()! 96 // Only analyze messages since the last check — the skill definition 97 // provides enough context for the classifier to understand corrections 98 const newMessages = context.messages.slice(lastAnalyzedIndex) 99 lastAnalyzedIndex = context.messages.length 100 101 return [ 102 createUserMessage({ 103 content: `You are analyzing a conversation where a user is executing a skill (a repeatable process). 104Your job: identify if the user's recent messages contain preferences, requests, or corrections that should be permanently added to the skill definition for future runs. 105 106<skill_definition> 107${projectSkill.content} 108</skill_definition> 109 110<recent_messages> 111${formatRecentMessages(newMessages)} 112</recent_messages> 113 114Look for: 115- Requests to add, change, or remove steps: "can you also ask me X", "please do Y too", "don't do Z" 116- Preferences about how steps should work: "ask me about energy levels", "note the time", "use a casual tone" 117- Corrections: "no, do X instead", "always use Y", "make sure to..." 118 119Ignore: 120- Routine conversation that doesn't generalize (one-time answers, chitchat) 121- Things the skill already does 122 123Output a JSON array inside <updates> tags. Each item: {"section": "which step/section to modify or 'new step'", "change": "what to add/modify", "reason": "which user message prompted this"}. 124Output <updates>[]</updates> if no updates are needed.`, 125 }), 126 ] 127 }, 128 129 systemPrompt: 130 'You detect user preferences and process improvements during skill execution. Flag anything the user asks for that should be remembered for next time.', 131 132 useTools: false, 133 134 parseResponse(content) { 135 const updatesStr = extractTag(content, 'updates') 136 if (!updatesStr) { 137 return [] 138 } 139 try { 140 return jsonParse(updatesStr) as SkillUpdate[] 141 } catch { 142 return [] 143 } 144 }, 145 146 logResult(result, context) { 147 if (result.type === 'success' && result.result.length > 0) { 148 const projectSkill = findProjectSkill() 149 const skillName = projectSkill?.skillName ?? 'unknown' 150 151 logEvent('tengu_skill_improvement_detected', { 152 updateCount: result.result 153 .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 154 uuid: result.uuid as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 155 // _PROTO_skill_name routes to the privileged skill_name BQ column. 156 _PROTO_skill_name: 157 skillName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 158 }) 159 160 context.toolUseContext.setAppState(prev => ({ 161 ...prev, 162 skillImprovement: { 163 suggestion: { skillName, updates: result.result }, 164 }, 165 })) 166 } 167 }, 168 169 getModel: getSmallFastModel, 170 } 171 172 return createApiQueryHook(config) 173} 174 175export function initSkillImprovement(): void { 176 if ( 177 feature('SKILL_IMPROVEMENT') && 178 getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_panda', false) 179 ) { 180 registerPostSamplingHook(createSkillImprovementHook()) 181 } 182} 183 184/** 185 * Apply skill improvements by calling a side-channel LLM to rewrite the skill file. 186 * Fire-and-forget — does not block the main conversation. 187 */ 188export async function applySkillImprovement( 189 skillName: string, 190 updates: SkillUpdate[], 191): Promise<void> { 192 if (!skillName) return 193 194 const { join } = await import('path') 195 const fs = await import('fs/promises') 196 197 // Skills live at .claude/skills/<name>/SKILL.md relative to CWD 198 const filePath = join(getCwd(), '.claude', 'skills', skillName, 'SKILL.md') 199 200 let currentContent: string 201 try { 202 currentContent = await fs.readFile(filePath, 'utf-8') 203 } catch { 204 logError( 205 new Error(`Failed to read skill file for improvement: ${filePath}`), 206 ) 207 return 208 } 209 210 const updateList = updates.map(u => `- ${u.section}: ${u.change}`).join('\n') 211 212 const response = await queryModelWithoutStreaming({ 213 messages: [ 214 createUserMessage({ 215 content: `You are editing a skill definition file. Apply the following improvements to the skill. 216 217<current_skill_file> 218${currentContent} 219</current_skill_file> 220 221<improvements> 222${updateList} 223</improvements> 224 225Rules: 226- Integrate the improvements naturally into the existing structure 227- Preserve frontmatter (--- block) exactly as-is 228- Preserve the overall format and style 229- Do not remove existing content unless an improvement explicitly replaces it 230- Output the complete updated file inside <updated_file> tags`, 231 }), 232 ], 233 systemPrompt: asSystemPrompt([ 234 'You edit skill definition files to incorporate user preferences. Output only the updated file content.', 235 ]), 236 thinkingConfig: { type: 'disabled' as const }, 237 tools: [], 238 signal: createAbortController().signal, 239 options: { 240 getToolPermissionContext: async () => getEmptyToolPermissionContext(), 241 model: getSmallFastModel(), 242 toolChoice: undefined, 243 isNonInteractiveSession: false, 244 hasAppendSystemPrompt: false, 245 temperatureOverride: 0, 246 agents: [], 247 querySource: 'skill_improvement_apply', 248 mcpTools: [], 249 }, 250 }) 251 252 const responseText = extractTextContent(response.message.content).trim() 253 254 const updatedContent = extractTag(responseText, 'updated_file') 255 if (!updatedContent) { 256 logError( 257 new Error('Skill improvement apply: no updated_file tag in response'), 258 ) 259 return 260 } 261 262 try { 263 await fs.writeFile(filePath, updatedContent, 'utf-8') 264 } catch (e) { 265 logError(toError(e)) 266 } 267}