mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 添加 skill learning 技能学习闭环系统
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
206
src/services/skillLearning/skillGenerator.ts
Normal file
206
src/services/skillLearning/skillGenerator.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { clearSkillIndexCache } from '../skillSearch/localSearch.js'
|
||||
import type { Instinct } from './instinctParser.js'
|
||||
import { buildLearnedSkillName, normalizeSkillName } from './learningPolicy.js'
|
||||
import {
|
||||
compareExistingArtifacts,
|
||||
scoreArtifactOverlap,
|
||||
type ExistingSkill,
|
||||
} from './skillLifecycle.js'
|
||||
import type { LearnedSkillDraft, SkillLearningScope } from './types.js'
|
||||
|
||||
export const DUPLICATE_SKILL_OVERLAP_THRESHOLD = 0.8
|
||||
|
||||
export type SkillGeneratorOptions = {
|
||||
cwd?: string
|
||||
globalSkillsDir?: string
|
||||
outputRoot?: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function generateSkillDraft(
|
||||
instincts: Instinct[],
|
||||
options?: SkillGeneratorOptions & { scope?: SkillLearningScope },
|
||||
): LearnedSkillDraft {
|
||||
if (instincts.length === 0) {
|
||||
throw new Error('Cannot generate a skill draft without instincts')
|
||||
}
|
||||
|
||||
const scope = options?.scope ?? instincts[0]?.scope ?? 'project'
|
||||
const name = options?.name
|
||||
? normalizeSkillName(options.name)
|
||||
: buildSkillName(instincts)
|
||||
const confidence =
|
||||
instincts.reduce((sum, instinct) => sum + instinct.confidence, 0) /
|
||||
instincts.length
|
||||
const description = options?.description ?? buildDescription(instincts)
|
||||
const outputPath = getLearnedSkillPath(name, scope, options)
|
||||
const content = buildSkillContent({
|
||||
name,
|
||||
description,
|
||||
confidence,
|
||||
instincts,
|
||||
})
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
scope,
|
||||
sourceInstinctIds: instincts.map(instinct => instinct.id),
|
||||
confidence: Number(confidence.toFixed(2)),
|
||||
content,
|
||||
outputPath,
|
||||
}
|
||||
}
|
||||
|
||||
export type SkillDedupOutcome =
|
||||
| { action: 'create'; draft: LearnedSkillDraft }
|
||||
| {
|
||||
action: 'append-evidence'
|
||||
target: ExistingSkill
|
||||
overlap: number
|
||||
appendedPath: string
|
||||
}
|
||||
|
||||
export async function generateOrMergeSkillDraft(
|
||||
instincts: Instinct[],
|
||||
options: SkillGeneratorOptions & { scope?: SkillLearningScope },
|
||||
existingRoots: string[],
|
||||
): Promise<SkillDedupOutcome> {
|
||||
const draft = generateSkillDraft(instincts, options)
|
||||
const candidates = await compareExistingArtifacts(
|
||||
'skill',
|
||||
draft,
|
||||
existingRoots,
|
||||
)
|
||||
for (const candidate of candidates) {
|
||||
const overlap = scoreArtifactOverlap(draft, candidate)
|
||||
if (overlap >= DUPLICATE_SKILL_OVERLAP_THRESHOLD) {
|
||||
const appendedPath = await appendInstinctEvidenceToSkill(
|
||||
candidate,
|
||||
instincts,
|
||||
)
|
||||
return {
|
||||
action: 'append-evidence',
|
||||
target: candidate,
|
||||
overlap,
|
||||
appendedPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { action: 'create', draft }
|
||||
}
|
||||
|
||||
export async function appendInstinctEvidenceToSkill(
|
||||
target: ExistingSkill,
|
||||
instincts: Instinct[],
|
||||
): Promise<string> {
|
||||
const existing = await readFile(target.path, 'utf8').catch(
|
||||
() => target.content,
|
||||
)
|
||||
const now = new Date().toISOString()
|
||||
const block = [
|
||||
'',
|
||||
`## Learned evidence (${now})`,
|
||||
'',
|
||||
...instincts.flatMap(instinct =>
|
||||
instinct.evidence.map(evidence => `- ${evidence}`),
|
||||
),
|
||||
'',
|
||||
].join('\n')
|
||||
const merged = existing.endsWith('\n')
|
||||
? existing + block
|
||||
: `${existing}\n${block}`
|
||||
await writeFile(target.path, merged, 'utf8')
|
||||
clearSkillIndexCache()
|
||||
return target.path
|
||||
}
|
||||
|
||||
export async function writeLearnedSkill(
|
||||
draft: LearnedSkillDraft,
|
||||
): Promise<string> {
|
||||
await mkdir(draft.outputPath, { recursive: true })
|
||||
const filePath = join(draft.outputPath, 'SKILL.md')
|
||||
await writeFile(filePath, draft.content, 'utf8')
|
||||
clearSkillIndexCache()
|
||||
try {
|
||||
const { clearCommandsCache } = await import('../../commands.js')
|
||||
clearCommandsCache()
|
||||
} catch {
|
||||
// Best effort: the next process will see the generated skill even if the
|
||||
// in-process command cache cannot be cleared due to import timing.
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
export function getLearnedSkillPath(
|
||||
name: string,
|
||||
scope: SkillLearningScope,
|
||||
options?: SkillGeneratorOptions,
|
||||
): string {
|
||||
if (options?.outputRoot) return join(options.outputRoot, name)
|
||||
if (scope === 'project') {
|
||||
return join(options?.cwd ?? process.cwd(), '.claude', 'skills', name)
|
||||
}
|
||||
return join(
|
||||
options?.globalSkillsDir ?? join(getClaudeConfigHomeDir(), 'skills'),
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
function buildSkillName(instincts: Instinct[]): string {
|
||||
return buildLearnedSkillName(instincts)
|
||||
}
|
||||
|
||||
function buildDescription(instincts: Instinct[]): string {
|
||||
const action = instincts[0]?.action ?? 'Apply a learned project pattern'
|
||||
const short = action.replace(/\s+/g, ' ').slice(0, 120)
|
||||
return short.length > 0 ? short : 'Apply learned project patterns'
|
||||
}
|
||||
|
||||
function buildSkillContent(params: {
|
||||
name: string
|
||||
description: string
|
||||
confidence: number
|
||||
instincts: Instinct[]
|
||||
}): string {
|
||||
const { name, description, confidence, instincts } = params
|
||||
const lines = [
|
||||
'---',
|
||||
`name: ${name}`,
|
||||
`description: ${JSON.stringify(description)}`,
|
||||
'origin: skill-learning',
|
||||
`confidence: ${Number(confidence.toFixed(2))}`,
|
||||
`evolved_from: [${instincts.map(instinct => JSON.stringify(instinct.id)).join(', ')}]`,
|
||||
'---',
|
||||
'',
|
||||
`# ${titleCase(name)}`,
|
||||
'',
|
||||
'## Trigger',
|
||||
'',
|
||||
instincts.map(instinct => `- ${instinct.trigger}`).join('\n'),
|
||||
'',
|
||||
'## Action',
|
||||
'',
|
||||
instincts.map(instinct => `- ${instinct.action}`).join('\n'),
|
||||
'',
|
||||
'## Evidence',
|
||||
'',
|
||||
instincts
|
||||
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
|
||||
.join('\n'),
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map(part => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
Reference in New Issue
Block a user