Skip to content

Commit c866a62

Browse files
committed
feat: --learn heuristic loop, LLM import assessment, usage docs
- --learn mode: runs 5 self-improvement heuristics after agent execution, detects multi-step procedures, error recoveries, user corrections, repeated actions, and explicit save instructions, then prompts user to save detected patterns as skills - Import LLM assessment: wires configured model into skill import, replaces hardcoded 'elevated' default with real API assessment call - SimpleCall method on llm.Client for lightweight single-turn prompts - ExtractToolCalls + RunAllHeuristics helpers in skills package - FormatSuggestion + SaveSuggestion user-facing helpers - Usage text documents --learn flag and kode skill subcommands - 14 new tests for extraction, suggestions, keyword derivation
1 parent 2432f08 commit c866a62

4 files changed

Lines changed: 523 additions & 21 deletions

File tree

cmd/kode/main.go

Lines changed: 122 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,12 @@ func printUsage() {
237237
238238
Commands:
239239
run Execute a task with the agent loop
240+
run --learn Execute with skill learning (detects patterns, suggests skills)
240241
run --session Execute and save conversation as a session
241242
continue Continue the most recent session (or by --id)
242243
repl Interactive REPL mode (multi-turn session)
243244
session Manage sessions: list, show, delete, trim, cleanup
245+
skill Manage skills: list, view, save, delete, import, curate
244246
init Create a config file (default: ./kode.json)
245247
version Print version and exit
246248
@@ -260,8 +262,17 @@ Run flags:
260262
--no-color Disable colored terminal output
261263
--no-agents Skip loading AGENTS.md from working directory
262264
--session Save conversation as a multi-turn session
265+
--learn Enable skill learning mode (detects patterns, saves skills)
263266
--system <prompt> System prompt override
264267
268+
Skill commands:
269+
kode skill list List all available skills
270+
kode skill view <name> View a skill's full content
271+
kode skill delete <name> Delete a skill
272+
kode skill import <uri> [flags] Import a skill from file:// or https://
273+
Flags: --basic (skip LLM), --yes (auto-approve)
274+
kode skill curate Analyze skills for quality, staleness, overlap
275+
265276
Sandbox flags:
266277
--sandbox Run in isolated Docker container
267278
--sandbox-image <img> Docker image (default: alpine:latest)
@@ -606,6 +617,10 @@ func run(args []string) error {
606617

607618
rend.Start(f.Task)
608619

620+
// Shared agent run — capture messages for --learn mode
621+
var allMessages []llm.Message
622+
var runErr error
623+
609624
if f.Session != nil && *f.Session {
610625
// Multi-turn session mode: save conversation history
611626
messages := []llm.Message{
@@ -615,30 +630,43 @@ func run(args []string) error {
615630
messages = append([]llm.Message{{Role: "system", Content: systemMessage}}, messages...)
616631
}
617632

618-
result, allMessages, err := agent.RunWithMessages(ctx, messages)
619-
if err != nil {
620-
return err
621-
}
633+
var result string
634+
result, allMessages, runErr = agent.RunWithMessages(ctx, messages)
622635
_ = result
623636

624-
store, err := session.NewStore()
625-
if err != nil {
626-
return fmt.Errorf("session store: %w", err)
627-
}
628-
sess, err := store.Create(allMessages, resolved.Model, f.Task)
629-
if err != nil {
630-
return fmt.Errorf("save session: %w", err)
637+
if runErr == nil {
638+
store, err := session.NewStore()
639+
if err != nil {
640+
return fmt.Errorf("session store: %w", err)
641+
}
642+
sess, err := store.Create(allMessages, resolved.Model, f.Task)
643+
if err != nil {
644+
return fmt.Errorf("save session: %w", err)
645+
}
646+
sess.Sandbox = resolved.Sandbox
647+
store.Save(sess)
648+
fmt.Fprintf(os.Stderr, "kode: session %s saved — continue with: kode continue \"...\"\n", sess.ID)
631649
}
632-
sess.Sandbox = resolved.Sandbox
633-
store.Save(sess)
634-
fmt.Fprintf(os.Stderr, "kode: session %s saved — continue with: kode continue \"...\"\n", sess.ID)
635650
} else {
636651
// Single-shot mode (default)
637-
_, err := agent.Run(ctx, f.Task)
638-
if err != nil {
639-
return err
652+
messages := []llm.Message{
653+
{Role: "user", Content: f.Task},
654+
}
655+
if systemMessage != "" {
656+
messages = append([]llm.Message{{Role: "system", Content: systemMessage}}, messages...)
640657
}
658+
_, allMessages, runErr = agent.RunWithMessages(ctx, messages)
641659
}
660+
661+
if runErr != nil {
662+
return runErr
663+
}
664+
665+
// ── Learn loop: run self-improvement heuristics ──
666+
if resolved.Skills.Learn && sm != nil {
667+
runLearnLoop(allMessages, f.Task, sm)
668+
}
669+
642670
return nil
643671
}
644672

@@ -803,6 +831,74 @@ func getVersion() string {
803831

804832
// ── Skill Commands ─────────────────────────────────────────────────────
805833

834+
// ── Skill Commands ─────────────────────────────────────────────────────
835+
836+
// runLearnLoop runs self-improvement heuristics on agent output and
837+
// offers to save detected patterns as skills.
838+
func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager) {
839+
// Convert llm.Message to skills.llmMessage
840+
skillMsgs := make([]skills.LlmMessage, 0, len(messages))
841+
for _, m := range messages {
842+
msg := skills.LlmMessage{
843+
Role: m.Role,
844+
Content: m.Content,
845+
Name: m.Name,
846+
ToolCallID: m.ToolCallID,
847+
}
848+
for _, tc := range m.ToolCalls {
849+
msg.ToolCalls = append(msg.ToolCalls, skills.LlmToolCall{
850+
ID: tc.ID,
851+
})
852+
msg.ToolCalls[len(msg.ToolCalls)-1].Function.Name = tc.Function.Name
853+
msg.ToolCalls[len(msg.ToolCalls)-1].Function.Arguments = tc.Function.Arguments
854+
}
855+
skillMsgs = append(skillMsgs, msg)
856+
}
857+
858+
userMessages := extractUserMessages(messages)
859+
suggestions := skills.RunAllHeuristics(skillMsgs, userMessages)
860+
if len(suggestions) == 0 {
861+
return
862+
}
863+
864+
fmt.Fprintf(os.Stderr, "\n🔍 Learning: detected %d skill pattern(s)\n", len(suggestions))
865+
for _, s := range suggestions {
866+
fmt.Fprint(os.Stderr, skills.FormatSuggestion(s))
867+
fmt.Fprintf(os.Stderr, " Save as skill? [Y/n]: ")
868+
869+
var response string
870+
fmt.Scanf("%s", &response)
871+
response = strings.ToLower(strings.TrimSpace(response))
872+
873+
if response == "" || response == "y" || response == "yes" {
874+
userDir := expandHome("~/.kode/skills")
875+
os.MkdirAll(userDir, 0755)
876+
if err := skills.SaveSuggestion(userDir, s); err != nil {
877+
fmt.Fprintf(os.Stderr, " ✗ Error saving skill: %v\n", err)
878+
} else {
879+
fmt.Fprintf(os.Stderr, " ✓ Saved skill %q\n", s.Name)
880+
// Reload the skill manager
881+
sm2 := skills.NewSkillManager(userDir, "./.kode/skills")
882+
// Update the caller's manager reference if possible
883+
_ = sm2
884+
}
885+
} else {
886+
fmt.Fprintf(os.Stderr, " Skipped.\n")
887+
}
888+
}
889+
}
890+
891+
// extractUserMessages extracts user message content from llm messages.
892+
func extractUserMessages(messages []llm.Message) []string {
893+
var out []string
894+
for _, m := range messages {
895+
if m.Role == "user" {
896+
out = append(out, m.Content)
897+
}
898+
}
899+
return out
900+
}
901+
806902
// skillCmd handles `kode skill <list|view|save|delete|import|curate>`.
807903
func skillCmd(args []string) error {
808904
if len(args) == 0 {
@@ -880,12 +976,18 @@ func skillCmd(args []string) error {
880976
}
881977

882978
llmCall := func(prompt string) (string, error) {
883-
// TODO: use configured LLM for assessment
884979
if basicOnly {
885980
return "", fmt.Errorf("basic mode — no LLM call")
886981
}
887-
// For now, return elevated as a safe default
888-
return `{"risk_class": "elevated", "reasons": ["no LLM configured for assessment"], "what_it_does": "imported skill", "recommended_triggers": [], "red_flags": []}`, nil
982+
// Load config and create LLM client for assessment
983+
cfg := config.LoadConfig(config.CLIFlags{})
984+
client := llm.New(cfg.BaseURL, cfg.APIKey, cfg.Model, "", 30)
985+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
986+
defer cancel()
987+
return client.SimpleCall(ctx,
988+
"You are a security assessment tool. Analyze skill files for risk.",
989+
prompt,
990+
)
889991
}
890992

891993
result, err := skills.ImportSkill(skills.ImportOptions{

internal/llm/client.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,65 @@ type CallResult struct {
9696
// toolChoiceNone forces the model to not call tools.
9797
var toolChoiceNone = "none"
9898

99+
// SimpleCall sends a single-turn chat completion request and returns the
100+
// text response. No tools, no streaming, no thinking config. Used for
101+
// lightweight LLM calls like skill risk assessment.
102+
func (c *Client) SimpleCall(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
103+
messages := []Message{
104+
{Role: "system", Content: systemPrompt},
105+
{Role: "user", Content: userPrompt},
106+
}
107+
108+
body := CallParams{
109+
Model: c.Model,
110+
Messages: messages,
111+
Stream: false,
112+
}
113+
114+
reqBytes, err := json.Marshal(body)
115+
if err != nil {
116+
return "", fmt.Errorf("llm: marshal request: %w", err)
117+
}
118+
119+
url := c.BaseURL + "/chat/completions"
120+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBytes))
121+
if err != nil {
122+
return "", fmt.Errorf("llm: create request: %w", err)
123+
}
124+
req.Header.Set("Content-Type", "application/json")
125+
req.Header.Set("Authorization", "Bearer "+c.APIKey)
126+
127+
resp, err := c.http.Do(req)
128+
if err != nil {
129+
return "", fmt.Errorf("llm: %w", err)
130+
}
131+
defer resp.Body.Close()
132+
133+
respBytes, err := io.ReadAll(resp.Body)
134+
if err != nil {
135+
return "", fmt.Errorf("llm: read response: %w", err)
136+
}
137+
138+
if resp.StatusCode != http.StatusOK {
139+
return "", fmt.Errorf("llm: %s (status %d): %s", resp.Status, resp.StatusCode, string(respBytes))
140+
}
141+
142+
var raw struct {
143+
Choices []struct {
144+
Message struct {
145+
Content string `json:"content"`
146+
} `json:"message"`
147+
} `json:"choices"`
148+
}
149+
if err := json.Unmarshal(respBytes, &raw); err != nil {
150+
return "", fmt.Errorf("llm: parse response: %w", err)
151+
}
152+
if len(raw.Choices) == 0 {
153+
return "", fmt.Errorf("llm: empty response")
154+
}
155+
return raw.Choices[0].Message.Content, nil
156+
}
157+
99158
// Call sends a chat completion request and returns the result.
100159
func (c *Client) Call(ctx context.Context, messages []Message, tools []ToolDef) (*CallResult, error) {
101160
body := CallParams{

0 commit comments

Comments
 (0)