| 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 |
| } |
|
|
| |
| 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()! |
| |
| |
| 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: |
| 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()) |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function applySkillImprovement( |
| skillName: string, |
| updates: SkillUpdate[], |
| ): Promise<void> { |
| if (!skillName) return |
|
|
| const { join } = await import('path') |
| const fs = await import('fs/promises') |
|
|
| |
| 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)) |
| } |
| } |
|
|