Files
claude-code/src/services/skillLearning/skillGenerator.ts
claude-code-best 1a1d57057e fix: 限制 skill-learning evidence 无限增长导致全局 skill 文件膨胀
evidence 数组和追加块缺少大小限制,导致 skill 文件(如
sdd-brainstorming)在短时间内膨胀至 21K+ 行/78 个 evidence 块。

三处修复:
- instinctParser: evidence 数组 cap 10 条, observationIds cap 20 条
- skillGenerator: 追加块每次最多 20 行, 文件总大小上限 50KB,
  生成 skill 的 evidence 段限制 20 行
- agentGenerator: 生成 agent 的 evidence 段限制 20 行

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:47:37 +08:00

232 lines
6.4 KiB
TypeScript

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
const MAX_EVIDENCE_LINES_PER_APPEND = 20
const MAX_EVIDENCE_LINES_IN_SKILL = 20
const MAX_SKILL_FILE_BYTES = 50_000
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,
)
// Skip if the file already exceeds the size cap
if (Buffer.byteLength(existing, 'utf8') >= MAX_SKILL_FILE_BYTES) {
return target.path
}
const allEvidence = instincts.flatMap(instinct =>
instinct.evidence.map(evidence => `- ${evidence}`),
)
const evidenceLines = allEvidence.slice(0, MAX_EVIDENCE_LINES_PER_APPEND)
if (evidenceLines.length < allEvidence.length) {
evidenceLines.push(
`- [... ${allEvidence.length - evidenceLines.length} more evidence entries omitted]`,
)
}
const now = new Date().toISOString()
const block = [
'',
`## Learned evidence (${now})`,
'',
...evidenceLines,
'',
].join('\n')
const merged = existing.endsWith('\n')
? existing + block
: `${existing}\n${block}`
// Final guard: truncate if merged exceeds size cap
const finalContent =
Buffer.byteLength(merged, 'utf8') > MAX_SKILL_FILE_BYTES
? merged.slice(0, MAX_SKILL_FILE_BYTES)
: merged
await writeFile(target.path, finalContent, '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}`))
.slice(0, MAX_EVIDENCE_LINES_IN_SKILL)
.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(' ')
}