File size: 8,362 Bytes
064bfd6 | 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 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | import { 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))
}
}
|