skillImprovement.ts
utils/hooks/skillImprovement.ts
No strong subsystem tag
268
Lines
8362
Bytes
3
Exports
18
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 268 lines, 18 detected imports, and 3 detected exports.
Important relationships
Detected exports
SkillUpdateinitSkillImprovementapplySkillImprovement
Keywords
skillcontentusermessagescontextresultupdatesskillnamemessageimprovements
Detected imports
bun:bundle../../bootstrap/state.js../../services/analytics/growthbook.js../../services/analytics/index.js../../services/api/claude.js../../Tool.js../../types/message.js../abortController.js../array.js../cwd.js../errors.js../log.js../messages.js../model/model.js../slowOperations.js../systemPromptType.js./apiQueryHookHelper.js./postSamplingHooks.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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))
}
}