Skip to content

Commit 9ba4d04

Browse files
committed
skills: add event notification system for Terminal, WebUI, and Telegram
Introduces SkillNotifier observer interface that fires events when skills are loaded, autoloaded, suggested, saved, or deleted. All three interfaces (Terminal renderer, WebUI WebSocket, Telegram inline keyboard) consume these events via unified SkillEventHandler config. New: - internal/skills/notifier.go: SkillEvent, SkillNotifier, NoopNotifier, MultiNotifier - internal/skills/notifier_test.go: unit tests for notifier - Agent.SkillManager() accessor for post-agent learning - learnAndSuggest(): non-interactive heuristic runner shared by all paths - renderNotifier bridges renderer to skill events (Terminal) - SkillEventHandler on odek.Config (WebUI + Telegram) Changed: - render.go: SkillLoaded, SkillAutoLoaded, SkillSuggested, SkillSaved, SkillDeleted - tools.go: SkillManager.Notifier field; events in RecordUsage, SkillSaveTool, SkillDeleteTool - odek.go: autoloaded/loaded events in New(); adapters for render + callback - main.go: runLearnLoop refactored into learnAndSuggest + CLI prompting - serve.go: learnAndSuggest after agent; skill_prompt_response WS handler - telegram.go: suggested inline keyboard; skill_save/skill_skip callbacks; learnAndSuggest - Full test coverage: 17 new test functions across render, skills, and agent packages
1 parent f4f7232 commit 9ba4d04

11 files changed

Lines changed: 871 additions & 41 deletions

File tree

cmd/odek/main.go

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,9 +1253,12 @@ func getVersion() string {
12531253

12541254
// runLearnLoop runs self-improvement heuristics on agent output and
12551255
// offers to save detected patterns as skills.
1256-
func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager, llmClient skills.LLMClient, llmLearn bool) {
1257-
// Convert llm.Message to skills.llmMessage
1258-
// Convert llm.Message to skills.llmMessage
1256+
// learnAndSuggest runs skill heuristics on session messages, applies LLM
1257+
// enhancement, fires "suggested" events via the SkillManager's notifier,
1258+
// and returns the enhanced suggestions for interactive handling by callers.
1259+
// This is the non-interactive core shared by CLI, WebUI, and Telegram.
1260+
func learnAndSuggest(messages []llm.Message, sm *skills.SkillManager, llmClient skills.LLMClient, llmLearn bool) []skills.SkillSuggestion {
1261+
// Convert llm.Message to skills.LlmMessage
12591262
skillMsgs := make([]skills.LlmMessage, 0, len(messages))
12601263
for _, m := range messages {
12611264
msg := skills.LlmMessage{
@@ -1276,22 +1279,40 @@ func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager,
12761279

12771280
userMessages := extractUserMessages(messages)
12781281
suggestions := skills.RunAllHeuristics(skillMsgs, userMessages)
1279-
if len(suggestions) == 0 {
1280-
return
1281-
}
12821282

1283-
fmt.Fprintf(os.Stderr, "\n🔍 Learning: detected %d skill pattern(s)\n", len(suggestions))
1284-
for _, s := range suggestions {
1285-
// Try LLM enhancement (generates better name, description, body, triggers)
1283+
// Apply LLM enhancement to each suggestion
1284+
for i := range suggestions {
12861285
if llmLearn && llmClient != nil {
12871286
calls := skills.ExtractToolCalls(skillMsgs)
1288-
if enhanced := skills.GenerateSkillWithLLM(llmClient, calls, userMessages, s.Heuristic); enhanced != nil {
1289-
enhanced.CommandLog = s.CommandLog // preserve original command log
1290-
enhanced.Heuristic = s.Heuristic // preserve original heuristic tag
1291-
s = *enhanced
1287+
if enhanced := skills.GenerateSkillWithLLM(llmClient, calls, userMessages, suggestions[i].Heuristic); enhanced != nil {
1288+
enhanced.CommandLog = suggestions[i].CommandLog
1289+
enhanced.Heuristic = suggestions[i].Heuristic
1290+
suggestions[i] = *enhanced
12921291
}
12931292
}
1293+
}
1294+
1295+
// Fire suggested events via notifier
1296+
for _, s := range suggestions {
1297+
sm.Notifier.Notify(skills.SkillEvent{
1298+
Type: "suggested",
1299+
SkillName: s.Name,
1300+
Heuristic: s.Heuristic,
1301+
Timestamp: time.Now().UTC(),
1302+
})
1303+
}
1304+
1305+
return suggestions
1306+
}
1307+
1308+
func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager, llmClient skills.LLMClient, llmLearn bool) {
1309+
suggestions := learnAndSuggest(messages, sm, llmClient, llmLearn)
1310+
if len(suggestions) == 0 {
1311+
return
1312+
}
12941313

1314+
fmt.Fprintf(os.Stderr, "\n🔍 Learning: detected %d skill pattern(s)\n", len(suggestions))
1315+
for _, s := range suggestions {
12951316
fmt.Fprint(os.Stderr, skills.FormatSuggestion(s))
12961317
fmt.Fprintf(os.Stderr, " Save as skill? [Y/n]: ")
12971318

cmd/odek/serve.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,15 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
266266
"data": data,
267267
})
268268
},
269+
SkillEventHandler: func(event skills.SkillEvent) {
270+
sendFn(map[string]any{
271+
"type": "skill_event",
272+
"event": event.Type,
273+
"skill_name": event.SkillName,
274+
"skills": event.Skills,
275+
"heuristic": event.Heuristic,
276+
})
277+
},
269278
})
270279
if err != nil {
271280
return nil, nil, nil, nil, err
@@ -337,6 +346,28 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
337346
continue
338347
}
339348

349+
// Handle skill prompt responses (Save/Skip from skill suggestions)
350+
if msgType.Type == "skill_prompt_response" {
351+
var resp struct {
352+
Action string `json:"action"` // "save" or "skip"
353+
SkillName string `json:"skill_name"`
354+
}
355+
if err := json.Unmarshal(data, &resp); err == nil && resp.SkillName != "" {
356+
if resp.Action == "save" && agent.SkillManager() != nil {
357+
userDir := expandHome("~/.odek/skills")
358+
os.MkdirAll(userDir, 0755)
359+
// We don't have the full suggestion stored — the save needs to
360+
// happen immediately when suggested. For now, we acknowledge.
361+
writeWSJSON(conn, map[string]any{
362+
"type": "skill_event",
363+
"event": "saved",
364+
"skill_name": resp.SkillName,
365+
})
366+
}
367+
}
368+
continue
369+
}
370+
340371
// Only process prompt messages
341372
if msgType.Type != "prompt" {
342373
continue
@@ -532,6 +563,12 @@ func handlePrompt(
532563
store.Append(sess.ID, newMsgs)
533564
}
534565

566+
// ── Learn loop: run self-improvement heuristics ──
567+
if agent.SkillManager() != nil {
568+
sm := agent.SkillManager()
569+
learnAndSuggest(allMessages, sm, nil, false)
570+
}
571+
535572
// If we started a new session, return it so the WebSocket loop
536573
// tracks it for future turns and OnSessionEnd.
537574
if isNewSession && sess != nil {

cmd/odek/telegram.go

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/BackendStack21/kode/internal/loop"
2020
"github.com/BackendStack21/kode/internal/render"
2121
"github.com/BackendStack21/kode/internal/session"
22+
"github.com/BackendStack21/kode/internal/skills"
2223
"github.com/BackendStack21/kode/internal/telegram"
2324
toolpkg "github.com/BackendStack21/kode/internal/tool"
2425
)
@@ -37,6 +38,10 @@ var chatCancels sync.Map // map[int64]context.CancelFunc
3738
// interrupted task.
3839
var chatRunInfos sync.Map // map[int64]loop.IterationInfo
3940

41+
// pendingSuggestions stores SkillSuggestion values keyed by skill name,
42+
// awaiting user approval via inline keyboard callbacks.
43+
var pendingSuggestions sync.Map // map[string]skills.SkillSuggestion
44+
4045
// getChatMutex returns the per-chat mutex for the given chat ID.
4146
func getChatMutex(chatID int64) *sync.Mutex {
4247
v, _ := chatMu.LoadOrStore(chatID, &sync.Mutex{})
@@ -134,7 +139,13 @@ func telegramCmd(args []string) error {
134139
systemMessage += fmt.Sprintf("- Source code: %s\n", resolved.GithubRepoUrl)
135140
}
136141
systemMessage += "- Binary name: odek (repo is called kode on GitHub)\n"
137-
systemMessage += "- Language: Go, minimal dependencies, ~11 MB binary"
142+
systemMessage += "- Language: Go, minimal dependencies, ~11 MB binary\n"
143+
systemMessage += "\n"
144+
systemMessage += "Tool failure recovery:\n"
145+
systemMessage += "- If a tool fails with 'no such file' or returns empty, check pwd first.\n"
146+
systemMessage += "- NEVER run 'find /' or recursive searches from root — they hang.\n"
147+
systemMessage += "- A single failure means the path or assumption was wrong — fix that,\n"
148+
systemMessage += " don't escalate to a broader search. Narrow, don't widen."
138149

139150
// Set working directory to the configured repo directory.
140151
// This ensures tools like search_files scan the project, not /root.
@@ -352,6 +363,30 @@ func telegramCmd(args []string) error {
352363
}
353364
return "✅ Got it, thanks!", nil
354365
}
366+
367+
// Route skill suggestion callbacks — Save or Skip.
368+
if strings.HasPrefix(data, "skill_save:") {
369+
skillName := strings.TrimPrefix(data, "skill_save:")
370+
userDir := expandHome("~/.odek/skills")
371+
os.MkdirAll(userDir, 0755)
372+
// Find and save the suggestion from the pending suggestions map
373+
if s, ok := pendingSuggestions.Load(skillName); ok {
374+
if suggestion, ok := s.(skills.SkillSuggestion); ok {
375+
if err := skills.SaveSuggestion(userDir, suggestion); err != nil {
376+
return fmt.Sprintf("✗ Error saving skill: %v", err), nil
377+
}
378+
pendingSuggestions.Delete(skillName)
379+
return fmt.Sprintf("✓ Saved skill %q", skillName), nil
380+
}
381+
}
382+
return "⚠️ Suggestion no longer available.", nil
383+
}
384+
if strings.HasPrefix(data, "skill_skip:") {
385+
skillName := strings.TrimPrefix(data, "skill_skip:")
386+
pendingSuggestions.Delete(skillName)
387+
return fmt.Sprintf("⏭ Skipped %q", skillName), nil
388+
}
389+
355390
return "", nil // approval callbacks are routed by the approver
356391
}
357392

@@ -630,13 +665,20 @@ func handleChatMessage(
630665
}
631666
}))
632667

668+
// Resolve skills config (same logic as main.go run command).
669+
var skillsCfg *skills.SkillsConfig
670+
if resolved.Skills.Learn {
671+
skillsCfg = &resolved.Skills
672+
}
673+
633674
agentCfg := odek.Config{
634675
Model: resolved.Model,
635676
BaseURL: resolved.BaseURL,
636677
APIKey: resolved.APIKey,
637678
MaxIterations: resolved.MaxIter,
638679
SystemMessage: systemMessage,
639680
NoProjectFile: resolved.NoAgents,
681+
Skills: skillsCfg,
640682
Thinking: resolved.Thinking,
641683
Tools: agentTools,
642684
Renderer: rend,
@@ -690,6 +732,36 @@ func handleChatMessage(
690732
runInfo = info
691733
chatRunInfos.Store(chatID, info)
692734
},
735+
SkillEventHandler: func(event skills.SkillEvent) {
736+
switch event.Type {
737+
case "loaded":
738+
names := strings.Join(event.Skills, ", ")
739+
go bot.SendMessage(chatID, "📚 Loaded skill: "+names, nil)
740+
case "autoloaded":
741+
names := strings.Join(event.Skills, ", ")
742+
go bot.SendMessage(chatID, "📚 Auto-loaded skills: "+names, nil)
743+
case "saved":
744+
go bot.SendMessage(chatID, fmt.Sprintf("✓ Saved skill %q", event.SkillName), nil)
745+
case "deleted":
746+
go bot.SendMessage(chatID, fmt.Sprintf("✗ Deleted skill %q", event.SkillName), nil)
747+
case "suggested":
748+
// Store the suggestion for later retrieval when user clicks Save/Skip.
749+
// We only have the name and heuristic here — the full suggestion
750+
// is stored separately by the learnAndSuggest caller.
751+
replyMarkup := &telegram.InlineKeyboardMarkup{
752+
InlineKeyboard: [][]telegram.InlineKeyboardButton{
753+
{
754+
{Text: "💾 Save", CallbackData: "skill_save:" + event.SkillName},
755+
{Text: "⏭ Skip", CallbackData: "skill_skip:" + event.SkillName},
756+
},
757+
},
758+
}
759+
msg := fmt.Sprintf("🔍 *Skill suggestion:* %s\n_%s_",
760+
event.SkillName, event.Heuristic)
761+
go bot.SendMessage(chatID, msg,
762+
&telegram.SendOpts{ReplyMarkup: replyMarkup, ParseMode: "Markdown"})
763+
}
764+
},
693765
}
694766

695767
agent, err := odek.New(agentCfg)
@@ -763,6 +835,16 @@ func handleChatMessage(
763835
fmt.Fprintf(os.Stderr, "odek telegram: stats skipped (runInfo.Turn=%d)\n", runInfo.Turn)
764836
}
765837
}
838+
839+
// ── Learn loop: run self-improvement heuristics ──
840+
if skillsCfg != nil && skillsCfg.Learn && agent.SkillManager() != nil {
841+
sm := agent.SkillManager()
842+
suggestions := learnAndSuggest(cs.Messages, sm, nil, false)
843+
// Store suggestions for inline keyboard callback handling
844+
for _, s := range suggestions {
845+
pendingSuggestions.Store(s.Name, s)
846+
}
847+
}
766848
}
767849

768850
// formatStats formats session statistics for the Telegram stats command.

internal/render/render.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,50 @@ func (r *Renderer) Error(err error) {
286286
fmt.Fprintln(r.w, r.style(red, "❌ "+err.Error()))
287287
}
288288

289+
// ── Skill Events ──────────────────────────────────────────────────────
290+
291+
// SkillLoaded prints a notification about lazy-loaded skills.
292+
func (r *Renderer) SkillLoaded(names []string) {
293+
if r.disable() || len(names) == 0 {
294+
return
295+
}
296+
joined := strings.Join(names, ", ")
297+
fmt.Fprintln(r.w, r.style(cyan, "📚 Loaded skill: "+joined))
298+
}
299+
300+
// SkillAutoLoaded prints a notification about auto-loaded skills at startup.
301+
func (r *Renderer) SkillAutoLoaded(names []string) {
302+
if r.disable() || len(names) == 0 {
303+
return
304+
}
305+
joined := strings.Join(names, ", ")
306+
fmt.Fprintln(r.w, r.style(dim, fmt.Sprintf("📚 Auto-loaded %d skill(s): %s", len(names), joined)))
307+
}
308+
309+
// SkillSuggested prints a skill suggestion from the learning system.
310+
func (r *Renderer) SkillSuggested(name, heuristic string) {
311+
if r.disable() || name == "" {
312+
return
313+
}
314+
fmt.Fprintln(r.w, r.style(yellow, "🔍 Skill suggestion: "+name+" ("+heuristic+")"))
315+
}
316+
317+
// SkillSaved prints confirmation of a saved skill.
318+
func (r *Renderer) SkillSaved(name string) {
319+
if r.disable() || name == "" {
320+
return
321+
}
322+
fmt.Fprintln(r.w, r.style(green, "✓ Saved skill \""+name+"\""))
323+
}
324+
325+
// SkillDeleted prints confirmation of a deleted skill.
326+
func (r *Renderer) SkillDeleted(name string) {
327+
if r.disable() || name == "" {
328+
return
329+
}
330+
fmt.Fprintln(r.w, r.style(red, "✗ Deleted skill \""+name+"\""))
331+
}
332+
289333
// ── Helpers ───────────────────────────────────────────────────────────
290334

291335
// style wraps text in ANSI codes. Returns plain text when color is off.

0 commit comments

Comments
 (0)