/ src / utils / hooks / skillImprovement.ts
skillImprovement.ts
  1  import { feature } from 'bun:bundle'
  2  import { getInvokedSkillsForAgent } from '../../bootstrap/state.js'
  3  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  4  import {
  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'
  9  import { queryModelWithoutStreaming } from '../../services/api/claude.js'
 10  import { getEmptyToolPermissionContext } from '../../Tool.js'
 11  import type { Message } from '../../types/message.js'
 12  import { createAbortController } from '../abortController.js'
 13  import { count } from '../array.js'
 14  import { getCwd } from '../cwd.js'
 15  import { toError } from '../errors.js'
 16  import { logError } from '../log.js'
 17  import {
 18    createUserMessage,
 19    extractTag,
 20    extractTextContent,
 21  } from '../messages.js'
 22  import { getSmallFastModel } from '../model/model.js'
 23  import { jsonParse } from '../slowOperations.js'
 24  import { asSystemPrompt } from '../systemPromptType.js'
 25  import {
 26    type ApiQueryHookConfig,
 27    createApiQueryHook,
 28  } from './apiQueryHookHelper.js'
 29  import { registerPostSamplingHook } from './postSamplingHooks.js'
 30  
 31  const TURN_BATCH_SIZE = 5
 32  
 33  export type SkillUpdate = {
 34    section: string
 35    change: string
 36    reason: string
 37  }
 38  
 39  function 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  
 58  function 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  
 68  function 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).
104  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.
105  
106  <skill_definition>
107  ${projectSkill.content}
108  </skill_definition>
109  
110  <recent_messages>
111  ${formatRecentMessages(newMessages)}
112  </recent_messages>
113  
114  Look 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  
119  Ignore:
120  - Routine conversation that doesn't generalize (one-time answers, chitchat)
121  - Things the skill already does
122  
123  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"}.
124  Output <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  
175  export 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   */
188  export 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  
225  Rules:
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  }