Skip to content

Commit d402733

Browse files
committed
feat: LLM-enhanced skill learning and curation
- New internal/skills/llm_enhance.go: GenerateSkillWithLLM and EnhanceCurationWithLLM use SimpleCall for better skill generation - SkillsConfig gains llm_learn and llm_curate (both default true) - runLearnLoop uses LLM to generate richer skill names, descriptions, bodies, and trigger keywords before presenting to user - Heuristic detection still runs first; LLM enriches the result - Zero new dependencies
1 parent a05d972 commit d402733

3 files changed

Lines changed: 184 additions & 2 deletions

File tree

cmd/kode/main.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,9 @@ func run(args []string) error {
818818

819819
// ── Learn loop: run self-improvement heuristics ──
820820
if resolved.Skills.Learn && sm != nil {
821-
runLearnLoop(allMessages, f.Task, sm)
821+
// Create LLM client for skill enhancement
822+
skillsLLM := llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 30*time.Second)
823+
runLearnLoop(allMessages, f.Task, sm, skillsLLM, resolved.Skills.LLMLearn)
822824
}
823825

824826
// ── Session end — extract episode if enough turns ──
@@ -1033,7 +1035,8 @@ func getVersion() string {
10331035

10341036
// runLearnLoop runs self-improvement heuristics on agent output and
10351037
// offers to save detected patterns as skills.
1036-
func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager) {
1038+
func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager, llmClient skills.LLMClient, llmLearn bool) {
1039+
// Convert llm.Message to skills.llmMessage
10371040
// Convert llm.Message to skills.llmMessage
10381041
skillMsgs := make([]skills.LlmMessage, 0, len(messages))
10391042
for _, m := range messages {
@@ -1061,6 +1064,16 @@ func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager)
10611064

10621065
fmt.Fprintf(os.Stderr, "\n🔍 Learning: detected %d skill pattern(s)\n", len(suggestions))
10631066
for _, s := range suggestions {
1067+
// Try LLM enhancement (generates better name, description, body, triggers)
1068+
if llmLearn && llmClient != nil {
1069+
calls := skills.ExtractToolCalls(skillMsgs)
1070+
if enhanced := skills.GenerateSkillWithLLM(llmClient, calls, userMessages, s.Heuristic); enhanced != nil {
1071+
enhanced.CommandLog = s.CommandLog // preserve original command log
1072+
enhanced.Heuristic = s.Heuristic // preserve original heuristic tag
1073+
s = *enhanced
1074+
}
1075+
}
1076+
10641077
fmt.Fprint(os.Stderr, skills.FormatSuggestion(s))
10651078
fmt.Fprintf(os.Stderr, " Save as skill? [Y/n]: ")
10661079

internal/skills/llm_enhance.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package skills
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
// LLMClient abstracts the LLM calls needed for skill enhancement.
10+
type LLMClient interface {
11+
SimpleCall(ctx context.Context, system, user string) (string, error)
12+
}
13+
14+
// GenerateSkillWithLLM takes heuristic-detected tool calls and user messages
15+
// and uses the LLM to generate a rich, accurate skill with proper name,
16+
// description, trigger keywords, and structured body.
17+
// Returns nil if the LLM call fails or returns empty output.
18+
func GenerateSkillWithLLM(llm LLMClient, calls []ToolCall, userMessages []string, heuristic string) *SkillSuggestion {
19+
if llm == nil {
20+
return nil
21+
}
22+
23+
// Build a compact summary of the session for the LLM
24+
var b strings.Builder
25+
b.WriteString("Analyze these tool calls from an AI coding agent session:\n\n")
26+
27+
// Add user messages (last 5)
28+
msgCount := len(userMessages)
29+
start := 0
30+
if msgCount > 5 {
31+
start = msgCount - 5
32+
}
33+
for i := start; i < msgCount; i++ {
34+
msg := userMessages[i]
35+
if len(msg) > 200 {
36+
msg = msg[:197] + "..."
37+
}
38+
b.WriteString(fmt.Sprintf("User: %s\n", msg))
39+
}
40+
41+
// Add tool calls (capped)
42+
limit := 10
43+
if len(calls) < limit {
44+
limit = len(calls)
45+
}
46+
for i := 0; i < limit; i++ {
47+
c := calls[i]
48+
input := c.Input
49+
if len(input) > 120 {
50+
input = input[:117] + "..."
51+
}
52+
status := "✓"
53+
if c.ExitCode != 0 {
54+
status = "✗"
55+
}
56+
b.WriteString(fmt.Sprintf("[%s] %s: %s\n", status, c.Tool, input))
57+
}
58+
if len(calls) > limit {
59+
b.WriteString(fmt.Sprintf("... and %d more calls\n", len(calls)-limit))
60+
}
61+
62+
b.WriteString(fmt.Sprintf("\nHeuristic trigger: %s\n", heuristic))
63+
b.WriteString("\nGenerate a skill file for this pattern. Output in this exact format:\n")
64+
b.WriteString("NAME: <short kebab-case name>\n")
65+
b.WriteString("DESCRIPTION: <one-line description, max 100 chars>\n")
66+
b.WriteString("TOPICS: <3-5 comma-separated topic keywords>\n")
67+
b.WriteString("ACTIONS: <2-3 comma-separated action keywords>\n")
68+
b.WriteString("BODY:\n")
69+
b.WriteString("<markdown body with ## Overview, ## Step-by-Step, ## Common Pitfalls, ## Verification sections>")
70+
71+
resp, err := llm.SimpleCall(context.Background(),
72+
"You are a skill authoring system. Given a session trace, generate a structured skill file. Output the exact format requested. Be concise and specific.",
73+
b.String(),
74+
)
75+
if err != nil || resp == "" {
76+
return nil
77+
}
78+
79+
return parseLLMSuggestion(resp)
80+
}
81+
82+
// parseLLMSuggestion parses the LLM's structured output into a SkillSuggestion.
83+
func parseLLMSuggestion(text string) *SkillSuggestion {
84+
s := &SkillSuggestion{
85+
Heuristic: "llm-enhanced",
86+
}
87+
88+
lines := strings.Split(text, "\n")
89+
var inBody bool
90+
var bodyLines []string
91+
92+
for _, line := range lines {
93+
trimmed := strings.TrimSpace(line)
94+
95+
if strings.HasPrefix(trimmed, "NAME:") {
96+
s.Name = strings.TrimSpace(strings.TrimPrefix(trimmed, "NAME:"))
97+
} else if strings.HasPrefix(trimmed, "DESCRIPTION:") {
98+
s.Description = strings.TrimSpace(strings.TrimPrefix(trimmed, "DESCRIPTION:"))
99+
} else if strings.HasPrefix(trimmed, "TOPICS:") {
100+
topics := strings.TrimSpace(strings.TrimPrefix(trimmed, "TOPICS:"))
101+
if topics != "" {
102+
for _, t := range strings.Split(topics, ",") {
103+
t = strings.TrimSpace(t)
104+
if t != "" {
105+
s.CommandLog = append(s.CommandLog, "topic:"+t)
106+
}
107+
}
108+
}
109+
} else if strings.HasPrefix(trimmed, "ACTIONS:") {
110+
actions := strings.TrimSpace(strings.TrimPrefix(trimmed, "ACTIONS:"))
111+
if actions != "" {
112+
for _, a := range strings.Split(actions, ",") {
113+
a = strings.TrimSpace(a)
114+
if a != "" {
115+
s.CommandLog = append(s.CommandLog, "action:"+a)
116+
}
117+
}
118+
}
119+
} else if strings.HasPrefix(trimmed, "BODY:") {
120+
inBody = true
121+
} else if inBody {
122+
bodyLines = append(bodyLines, line)
123+
}
124+
}
125+
126+
if s.Name == "" || len(bodyLines) == 0 {
127+
return nil
128+
}
129+
130+
s.Body = strings.Join(bodyLines, "\n")
131+
s.Body = strings.TrimSpace(s.Body)
132+
return s
133+
}
134+
135+
// EnhanceCurationWithLLM uses the LLM to assess skill quality and suggest
136+
// improvements. Returns a message describing findings, or empty string.
137+
func EnhanceCurationWithLLM(llm LLMClient, report *CurationReport) string {
138+
if llm == nil || report == nil || report.TotalSkills == 0 {
139+
return ""
140+
}
141+
142+
var b strings.Builder
143+
b.WriteString("Review these skills and identify quality issues, overlap, or improvement suggestions:\n\n")
144+
for _, s := range report.QualityIssues {
145+
b.WriteString(fmt.Sprintf("- %s: %s\n", s.Name, strings.Join(s.Issues, "; ")))
146+
}
147+
for _, g := range report.OverlapGroups {
148+
b.WriteString(fmt.Sprintf("- Overlap: %s share keywords: %s\n", strings.Join(g.Skills, ", "), strings.Join(g.Shared, ", ")))
149+
}
150+
b.WriteString("\nSuggest specific improvements or merge recommendations. Be concise.")
151+
152+
resp, err := llm.SimpleCall(context.Background(),
153+
"You are a skill curation assistant. Review the quality report and suggest actionable improvements. Keep it brief.",
154+
b.String(),
155+
)
156+
if err != nil {
157+
return ""
158+
}
159+
160+
resp = strings.TrimSpace(resp)
161+
if resp == "" {
162+
return ""
163+
}
164+
return resp
165+
}

internal/skills/types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ type SkillsConfig struct {
7373
Dirs []string `json:"dirs,omitempty"`
7474
Import ImportConfig `json:"import"`
7575
Curation CurationConfig `json:"curation"`
76+
LLMLearn bool `json:"llm_learn"`
77+
LLMCurate bool `json:"llm_curate"`
7678
}
7779

7880
// ImportConfig controls the URI import flow.
@@ -104,6 +106,8 @@ func DefaultSkillsConfig() SkillsConfig {
104106
StalenessDays: 90,
105107
AutoPrune: false,
106108
},
109+
LLMLearn: true,
110+
LLMCurate: true,
107111
}
108112
}
109113

0 commit comments

Comments
 (0)