Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
8.4 kB · 267 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268import { feature } from 'bun:bundle'import { getInvokedSkillsForAgent } from '../../bootstrap/state.js'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent,} from '../../services/analytics/index.js'import { queryModelWithoutStreaming } from '../../services/api/claude.js'import { getEmptyToolPermissionContext } from '../../Tool.js'import type { Message } from '../../types/message.js'import { createAbortController } from '../abortController.js'import { count } from '../array.js'import { getCwd } from '../cwd.js'import { toError } from '../errors.js'import { logError } from '../log.js'import { createUserMessage, extractTag, extractTextContent,} from '../messages.js'import { getSmallFastModel } from '../model/model.js'import { jsonParse } from '../slowOperations.js'import { asSystemPrompt } from '../systemPromptType.js'import { type ApiQueryHookConfig, createApiQueryHook,} from './apiQueryHookHelper.js'import { registerPostSamplingHook } from './postSamplingHooks.js'
const TURN_BATCH_SIZE = 5
export type SkillUpdate = { section: string change: string reason: string}
function formatRecentMessages(messages: Message[]): string { return messages .filter(m => m.type === 'user' || m.type === 'assistant') .map(m => { const role = m.type === 'user' ? 'User' : 'Assistant' const content = m.message.content if (typeof content === 'string') return `${role}: ${content.slice(0, 500)}` const text = content .filter( (b): b is Extract<typeof b, { type: 'text' }> => b.type === 'text', ) .map(b => b.text) .join('\n') return `${role}: ${text.slice(0, 500)}` }) .join('\n\n')}
function findProjectSkill() { const skills = getInvokedSkillsForAgent(null) for (const [, info] of skills) { if (info.skillPath.startsWith('projectSettings:')) { return info } } return undefined}
function createSkillImprovementHook() { let lastAnalyzedCount = 0 let lastAnalyzedIndex = 0
const config: ApiQueryHookConfig<SkillUpdate[]> = { name: 'skill_improvement',
async shouldRun(context) { if (context.querySource !== 'repl_main_thread') { return false }
if (!findProjectSkill()) { return false }
// Only run every TURN_BATCH_SIZE user messages const userCount = count(context.messages, m => m.type === 'user') if (userCount - lastAnalyzedCount < TURN_BATCH_SIZE) { return false }
lastAnalyzedCount = userCount return true },
buildMessages(context) { const projectSkill = findProjectSkill()! // Only analyze messages since the last check — the skill definition // provides enough context for the classifier to understand corrections const newMessages = context.messages.slice(lastAnalyzedIndex) lastAnalyzedIndex = context.messages.length
return [ createUserMessage({ content: `You are analyzing a conversation where a user is executing a skill (a repeatable process).Your 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.
<skill_definition>${projectSkill.content}</skill_definition>
<recent_messages>${formatRecentMessages(newMessages)}</recent_messages>
Look for:- Requests to add, change, or remove steps: "can you also ask me X", "please do Y too", "don't do Z"- Preferences about how steps should work: "ask me about energy levels", "note the time", "use a casual tone"- Corrections: "no, do X instead", "always use Y", "make sure to..."
Ignore:- Routine conversation that doesn't generalize (one-time answers, chitchat)- Things the skill already does
Output 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"}.Output <updates>[]</updates> if no updates are needed.`, }), ] },
systemPrompt: 'You detect user preferences and process improvements during skill execution. Flag anything the user asks for that should be remembered for next time.',
useTools: false,
parseResponse(content) { const updatesStr = extractTag(content, 'updates') if (!updatesStr) { return [] } try { return jsonParse(updatesStr) as SkillUpdate[] } catch { return [] } },
logResult(result, context) { if (result.type === 'success' && result.result.length > 0) { const projectSkill = findProjectSkill() const skillName = projectSkill?.skillName ?? 'unknown'
logEvent('tengu_skill_improvement_detected', { updateCount: result.result .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, uuid: result.uuid as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // _PROTO_skill_name routes to the privileged skill_name BQ column. _PROTO_skill_name: skillName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, })
context.toolUseContext.setAppState(prev => ({ ...prev, skillImprovement: { suggestion: { skillName, updates: result.result }, }, })) } },
getModel: getSmallFastModel, }
return createApiQueryHook(config)}
export function initSkillImprovement(): void { if ( feature('SKILL_IMPROVEMENT') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_panda', false) ) { registerPostSamplingHook(createSkillImprovementHook()) }}
/** * Apply skill improvements by calling a side-channel LLM to rewrite the skill file. * Fire-and-forget — does not block the main conversation. */export async function applySkillImprovement( skillName: string, updates: SkillUpdate[],): Promise<void> { if (!skillName) return
const { join } = await import('path') const fs = await import('fs/promises')
// Skills live at .claude/skills/<name>/SKILL.md relative to CWD const filePath = join(getCwd(), '.claude', 'skills', skillName, 'SKILL.md')
let currentContent: string try { currentContent = await fs.readFile(filePath, 'utf-8') } catch { logError( new Error(`Failed to read skill file for improvement: ${filePath}`), ) return }
const updateList = updates.map(u => `- ${u.section}: ${u.change}`).join('\n')
const response = await queryModelWithoutStreaming({ messages: [ createUserMessage({ content: `You are editing a skill definition file. Apply the following improvements to the skill.
<current_skill_file>${currentContent}</current_skill_file>
<improvements>${updateList}</improvements>
Rules:- Integrate the improvements naturally into the existing structure- Preserve frontmatter (--- block) exactly as-is- Preserve the overall format and style- Do not remove existing content unless an improvement explicitly replaces it- Output the complete updated file inside <updated_file> tags`, }), ], systemPrompt: asSystemPrompt([ 'You edit skill definition files to incorporate user preferences. Output only the updated file content.', ]), thinkingConfig: { type: 'disabled' as const }, tools: [], signal: createAbortController().signal, options: { getToolPermissionContext: async () => getEmptyToolPermissionContext(), model: getSmallFastModel(), toolChoice: undefined, isNonInteractiveSession: false, hasAppendSystemPrompt: false, temperatureOverride: 0, agents: [], querySource: 'skill_improvement_apply', mcpTools: [], }, })
const responseText = extractTextContent(response.message.content).trim()
const updatedContent = extractTag(responseText, 'updated_file') if (!updatedContent) { logError( new Error('Skill improvement apply: no updated_file tag in response'), ) return }
try { await fs.writeFile(filePath, updatedContent, 'utf-8') } catch (e) { logError(toError(e)) }}