package evolution import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" ) type DraftGenerator interface { GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) } type EvidenceAwareDraftGenerator interface { GenerateDraftWithEvidence( ctx context.Context, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence, ) (SkillDraft, error) } type DraftEvidence struct { TaskRecords []LearningRecord } func ValidateDraft(draft SkillDraft) []string { findings := make([]string, 0, 5) if strings.TrimSpace(draft.TargetSkillName) == "" { findings = append(findings, "target_skill_name is required") } else if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil { findings = append(findings, "target_skill_name is invalid: "+err.Error()) } else if isNumericToken(strings.TrimSpace(draft.TargetSkillName)) { findings = append(findings, "target_skill_name must be descriptive, not numeric-only") } if strings.TrimSpace(draft.HumanSummary) == "" { findings = append(findings, "human_summary is required") } if strings.TrimSpace(draft.BodyOrPatch) == "" { findings = append(findings, "body_or_patch is required") } switch draft.DraftType { case DraftTypeWorkflow, DraftTypeShortcut: default: findings = append(findings, "draft_type is invalid") } switch draft.ChangeKind { case ChangeKindCreate, ChangeKindAppend, ChangeKindReplace, ChangeKindMerge: default: findings = append(findings, "change_kind is invalid") } return findings } type DefaultDraftGenerator struct { loader *skills.SkillsLoader } func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator { builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills)) if builtinSkillsDir == "" { wd, _ := os.Getwd() builtinSkillsDir = filepath.Join(wd, "skills") } globalSkillsDir := filepath.Join(config.GetHome(), "skills") return &DefaultDraftGenerator{ loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), } } func (g *DefaultDraftGenerator) GenerateDraft( _ context.Context, rule LearningRecord, matches []skills.SkillInfo, ) (SkillDraft, error) { return g.GenerateDraftWithEvidence(context.Background(), rule, matches, DraftEvidence{}) } func (g *DefaultDraftGenerator) GenerateDraftWithEvidence( _ context.Context, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence, ) (SkillDraft, error) { rule = enrichRuleWithDraftEvidence(rule, evidence) target := inferTargetSkillName(rule, matches) if target == "" { target = "learned-skill" } _, hasExisting, err := g.loadBaseSkillContent(target, matches) if err != nil { return SkillDraft{}, err } draftType := DraftTypeWorkflow if len(rule.WinningPath) <= 1 { draftType = DraftTypeShortcut } changeKind := ChangeKindCreate body := g.buildNewSkillBody(target, rule, evidence, matches) if hasExisting { changeKind = ChangeKindAppend body = g.buildAppendBody(rule, evidence, matches) } return SkillDraft{ TargetSkillName: target, DraftType: draftType, ChangeKind: changeKind, HumanSummary: g.buildHumanSummary(target, rule, hasExisting), IntendedUseCases: inferIntendedUseCases(rule), PreferredEntryPath: inferPreferredEntryPath(rule), AvoidPatterns: inferAvoidPatterns(rule), BodyOrPatch: body, }, nil } func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string { if target := inferCombinedSkillName(rule); target != "" { return target } if label := validSkillNameOrEmpty(rule.Label); label != "" { return label } if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" { return strings.TrimSpace(matches[0].Name) } if len(rule.LateAddedSkills) > 0 && strings.TrimSpace(rule.LateAddedSkills[0]) != "" { return strings.TrimSpace(rule.LateAddedSkills[0]) } if len(rule.WinningPath) > 0 && strings.TrimSpace(rule.WinningPath[0]) != "" { return strings.TrimSpace(rule.WinningPath[0]) } if len(rule.MatchedSkillNames) > 0 && strings.TrimSpace(rule.MatchedSkillNames[0]) != "" { return strings.TrimSpace(rule.MatchedSkillNames[0]) } tokens := tokenizeForEvolution(rule.Summary) if len(tokens) > 0 { if len(tokens) == 1 && isNumericToken(tokens[0]) { return "learned-" + tokens[0] } return tokens[0] } return "" } func enrichRuleWithDraftEvidence(rule LearningRecord, evidence DraftEvidence) LearningRecord { if len(evidence.TaskRecords) == 0 { return rule } usedSkillNames := make([]string, 0) pathCounts := make(map[string]int) pathByKey := make(map[string][]string) for _, task := range evidence.TaskRecords { path := uniqueTrimmedNames(task.UsedSkillNames) if len(path) == 0 { continue } usedSkillNames = append(usedSkillNames, path...) key := strings.Join(path, "\x00") pathCounts[key]++ pathByKey[key] = path } rule.MatchedSkillNames = appendUniqueStrings(rule.MatchedSkillNames, uniqueTrimmedNames(usedSkillNames)...) if len(rule.WinningPath) == 0 { bestKey := "" bestCount := 0 for key, count := range pathCounts { if count > bestCount || (count == bestCount && key < bestKey) { bestKey = key bestCount = count } } if bestKey != "" { rule.WinningPath = append([]string(nil), pathByKey[bestKey]...) } } return rule } func inferCombinedSkillName(rule LearningRecord) string { path := normalizePath(rule.WinningPath) if len(path) < 2 { return "" } tokens := tokenizeForEvolution(rule.Summary) suffix := commonWinningPathSuffix(path) if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" { if candidate := validSkillNameOrEmpty( "calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix), ); candidate != "" { return candidate } } if len(tokens) >= 2 { prefix := strings.Join(tokens[:minInt(len(tokens), 4)], "-") if suffix != "" { if candidate := validSkillNameOrEmpty(prefix + "-via-" + pluralizeSuffix(suffix)); candidate != "" { return candidate } } if candidate := validSkillNameOrEmpty(prefix + "-shortcut"); candidate != "" { return candidate } } compressedPath := compressedWinningPathName(path) if candidate := validSkillNameOrEmpty("combined-" + compressedPath); candidate != "" { return candidate } if candidate := validSkillNameOrEmpty(path[0] + "-to-" + path[len(path)-1] + "-shortcut"); candidate != "" { return candidate } return "" } func commonWinningPathSuffix(path []string) string { if len(path) < 2 { return "" } var suffix string for i, name := range path { parts := strings.Split(strings.TrimSpace(name), "-") if len(parts) == 0 { return "" } last := strings.TrimSpace(parts[len(parts)-1]) if last == "" { return "" } if i == 0 { suffix = last continue } if suffix != last { return "" } } return suffix } func compressedWinningPathName(path []string) string { suffix := commonWinningPathSuffix(path) fragments := make([]string, 0, len(path)+1) for _, name := range path { trimmed := strings.TrimSpace(name) if trimmed == "" { continue } if suffix != "" { trimmed = strings.TrimSuffix(trimmed, "-"+suffix) trimmed = strings.TrimSuffix(trimmed, suffix) trimmed = strings.Trim(trimmed, "-") } if trimmed != "" { fragments = append(fragments, trimmed) } } if suffix != "" { fragments = append(fragments, pluralizeSuffix(suffix)) } if len(fragments) == 0 { return strings.Join(path, "-") } return strings.Join(fragments, "-") } func pluralizeSuffix(suffix string) string { suffix = strings.TrimSpace(strings.ToLower(suffix)) if suffix == "" { return "" } if strings.HasSuffix(suffix, "s") { return suffix } return suffix + "s" } func isNumericToken(value string) bool { if value == "" { return false } for _, r := range value { if r < '0' || r > '9' { return false } } return true } func validSkillNameOrEmpty(candidate string) string { candidate = strings.Trim(candidate, "-") candidate = strings.Join(strings.FieldsFunc(candidate, func(r rune) bool { return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') }), "-") candidate = strings.ToLower(strings.Trim(candidate, "-")) if candidate == "" { return "" } if len(candidate) > skills.MaxNameLength { return "" } if err := skills.ValidateSkillName(candidate); err != nil { return "" } return candidate } func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) { for _, match := range matches { if match.Name != target || strings.TrimSpace(match.Path) == "" { continue } data, err := os.ReadFile(match.Path) if err != nil { return "", false, err } return string(data), true, nil } if g.loader == nil { return "", false, nil } content, ok := g.loader.LoadSkill(target) if !ok { return "", false, nil } description := fmt.Sprintf("Use this skill to %s when the task requires this workflow.", sentenceFragment(target)) return buildSkillDocument(target, description, content), true, nil } func (g *DefaultDraftGenerator) buildHumanSummary(target string, rule LearningRecord, hasExisting bool) string { if hasExisting { return fmt.Sprintf("Refresh %s with learned pattern: %s", target, rule.Summary) } return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary) } func (g *DefaultDraftGenerator) buildNewSkillBody( target string, rule LearningRecord, evidence DraftEvidence, matches []skills.SkillInfo, ) string { description := fmt.Sprintf( "Use this skill to %s when the task matches this workflow.", sentenceFragment(fallbackString(rule.Summary, target)), ) body := strings.Join([]string{ "# " + titleCaseSkillName(target), "", "## Start Here", g.startHereLine(rule), "", "## When To Use", fmt.Sprintf("Use this skill when the task matches `%s`.", strings.TrimSpace(rule.Summary)), "", "## Learned Pattern", g.learnedPatternLine(rule), "", "## Procedure", g.procedureLine(rule, evidence), "", "## Expected Result", g.expectedResultLine(evidence), "", "## Source Skills", synthesizedComponentBreakdown(matches), "", "## Source Evidence", g.evidenceLine(rule, evidence), }, "\n") return buildSkillDocument(target, description, body) } func (g *DefaultDraftGenerator) buildAppendBody( rule LearningRecord, evidence DraftEvidence, matches []skills.SkillInfo, ) string { return strings.Join([]string{ "## Learned Evolution", fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)), fmt.Sprintf("- Learned pattern: %s", g.learnedPatternLine(rule)), fmt.Sprintf("- Procedure: %s", g.procedureLine(rule, evidence)), fmt.Sprintf("- Expected result: %s", g.expectedResultLine(evidence)), fmt.Sprintf("- Evidence: %s", g.evidenceLine(rule, evidence)), "", "### Source Skills", synthesizedComponentBreakdown(matches), "", }, "\n") } func buildSkillDocument(name, description, body string) string { return strings.Join([]string{ "---", "name: " + strings.TrimSpace(name), "description: " + strings.TrimSpace(description), "---", "", strings.TrimSpace(body), "", }, "\n") } func titleCaseSkillName(name string) string { parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == ' ' }) for i, part := range parts { if part == "" { continue } parts[i] = strings.ToUpper(part[:1]) + part[1:] } if len(parts) == 0 { return "Learned Skill" } return strings.Join(parts, " ") } func (g *DefaultDraftGenerator) startHereLine(rule LearningRecord) string { if len(rule.WinningPath) > 0 { return fmt.Sprintf("Start with `%s` before trying other paths.", strings.Join(rule.WinningPath, " -> ")) } return fmt.Sprintf("Start from the learned path for `%s`.", strings.TrimSpace(rule.Summary)) } func (g *DefaultDraftGenerator) learnedPatternLine(rule LearningRecord) string { if len(rule.LateAddedSkills) > 0 { return fmt.Sprintf( "Late-added skill `%s` was repeatedly introduced immediately before success%s.", strings.Join(rule.LateAddedSkills, " -> "), triggerSuffix(rule.FinalSnapshotTrigger), ) } if len(rule.WinningPath) > 0 { return fmt.Sprintf( "Prefer `%s` because it was the most reliable recent path.", strings.Join(rule.WinningPath, " -> "), ) } return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary)) } func (g *DefaultDraftGenerator) procedureLine(rule LearningRecord, evidence DraftEvidence) string { if len(rule.WinningPath) > 0 { return fmt.Sprintf( "Follow `%s`, applying the concrete operation from each source skill, then return the final result directly.", strings.Join(rule.WinningPath, " -> "), ) } if excerpt := firstFinalOutputExcerpt(evidence, 260); excerpt != "" { return "Use the same operation demonstrated by the source task result: " + excerpt } return fmt.Sprintf( "Solve tasks matching `%s` using the learned successful workflow, then return the final result directly.", strings.TrimSpace(rule.Summary), ) } func (g *DefaultDraftGenerator) expectedResultLine(evidence DraftEvidence) string { if excerpt := firstFinalOutputExcerpt(evidence, 320); excerpt != "" { return excerpt } return "Return the completed result for the matched task without restating unrelated discovery steps." } func (g *DefaultDraftGenerator) evidenceLine(rule LearningRecord, evidence DraftEvidence) string { if len(evidence.TaskRecords) > 0 { ids := make([]string, 0, len(evidence.TaskRecords)) for _, task := range evidence.TaskRecords { ids = append(ids, task.ID) } return fmt.Sprintf("Learned from task records: %s", strings.Join(ids, ", ")) } if len(rule.TaskRecordIDs) > 0 { return fmt.Sprintf("Learned from task records: %s", strings.Join(rule.TaskRecordIDs, ", ")) } return "Learned from the pattern record." } func firstFinalOutputExcerpt(evidence DraftEvidence, maxLen int) string { for _, task := range evidence.TaskRecords { if excerpt := summarizeText(task.FinalOutput, maxLen); excerpt != "" { return excerpt } } return "" } func triggerSuffix(trigger string) string { trigger = strings.TrimSpace(trigger) if trigger == "" { return "" } return fmt.Sprintf(" during `%s`", trigger) }