Files
claude-code/src/services/skillLearning/agentGenerator.ts
2026-04-22 22:38:09 +08:00

165 lines
4.4 KiB
TypeScript

import { mkdir, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { clearCommandsCache } from '../../commands.js'
import type { Instinct } from './instinctParser.js'
import { normalizeSkillName } from './learningPolicy.js'
import type { SkillLearningScope } from './types.js'
export type AgentGeneratorOptions = {
cwd?: string
globalAgentsDir?: string
outputRoot?: string
name?: string
description?: string
scope?: SkillLearningScope
}
export type LearnedAgentDraft = {
name: string
description: string
scope: SkillLearningScope
sourceInstinctIds: string[]
confidence: number
content: string
outputPath: string
}
export function generateAgentDraft(
instincts: Instinct[],
options?: AgentGeneratorOptions,
): LearnedAgentDraft {
if (instincts.length === 0) {
throw new Error('Cannot generate an agent draft without instincts')
}
const scope = options?.scope ?? instincts[0]?.scope ?? 'project'
const rawName = options?.name ?? buildAgentName(instincts)
const name = normalizeSkillName(rawName)
const confidence = averageConfidence(instincts)
const description = options?.description ?? buildDescription(instincts)
const outputPath = getLearnedAgentPath(name, scope, options)
const content = buildAgentContent({
name,
description,
confidence,
instincts,
})
return {
name,
description,
scope,
sourceInstinctIds: instincts.map(instinct => instinct.id),
confidence: Number(confidence.toFixed(2)),
content,
outputPath,
}
}
export async function writeLearnedAgent(
draft: LearnedAgentDraft,
): Promise<string> {
await mkdir(draft.outputPath, { recursive: true })
const filePath = join(draft.outputPath, `${draft.name}.md`)
if (existsSync(filePath)) return filePath
await writeFile(filePath, draft.content, 'utf8')
clearCommandsCache()
return filePath
}
export function getLearnedAgentPath(
_name: string,
scope: SkillLearningScope,
options?: AgentGeneratorOptions,
): string {
if (options?.outputRoot) return options.outputRoot
if (scope === 'project') {
return join(options?.cwd ?? process.cwd(), '.claude', 'agents')
}
return options?.globalAgentsDir ?? join(getClaudeConfigHomeDir(), 'agents')
}
function buildAgentName(instincts: Instinct[]): string {
const words = extractWords(instincts, 4)
const name = ['learned', 'agent', ...words].join('-')
return normalizeSkillName(name) || 'learned-agent'
}
function buildDescription(instincts: Instinct[]): string {
const trigger = instincts[0]?.trigger ?? 'Run the learned multi-step workflow'
return trigger.replace(/\s+/g, ' ').slice(0, 120)
}
function buildAgentContent(params: {
name: string
description: string
confidence: number
instincts: Instinct[]
}): string {
const { name, description, confidence, instincts } = params
return [
'---',
`name: ${name}`,
`description: ${JSON.stringify(description)}`,
'origin: skill-learning',
`confidence: ${Number(confidence.toFixed(2))}`,
`evolved_from: [${instincts.map(instinct => JSON.stringify(instinct.id)).join(', ')}]`,
'---',
'',
`You are the ${name} learned agent.`,
'',
'## Triggers',
'',
instincts.map(instinct => `- ${instinct.trigger}`).join('\n'),
'',
'## Playbook',
'',
instincts.map(instinct => `- ${instinct.action}`).join('\n'),
'',
'## Evidence',
'',
instincts
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
.join('\n'),
'',
].join('\n')
}
function averageConfidence(instincts: Instinct[]): number {
return (
instincts.reduce((sum, instinct) => sum + instinct.confidence, 0) /
instincts.length
)
}
function extractWords(instincts: Instinct[], max: number): string[] {
const stopWords = new Set([
'when',
'with',
'this',
'that',
'user',
'asks',
'for',
'the',
'and',
'debug',
'investigate',
'research',
])
const words: string[] = []
for (const instinct of instincts) {
for (const token of `${instinct.trigger} ${instinct.action}`
.toLowerCase()
.split(/[^a-z0-9]+/)) {
if (token.length > 2 && !stopWords.has(token) && !words.includes(token)) {
words.push(token)
}
if (words.length >= max) return words
}
}
return words
}