Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add skill runtime command

Lyric 9b86f3fd 3d0de2ca

+439 -13
+11 -1
cmd/mistermorph/chatcmd/commands.go
··· 7 7 "path/filepath" 8 8 9 9 "github.com/quailyquaily/mistermorph/internal/chatcommands" 10 + "github.com/quailyquaily/mistermorph/internal/skillsutil" 10 11 "github.com/quailyquaily/mistermorph/internal/workspace" 11 12 "github.com/quailyquaily/mistermorph/llm" 12 13 ) ··· 35 36 reg.Register("/memory", func(ctx context.Context, args string) (*chatcommands.Result, error) { 36 37 handleMemory(writer, sess.memOrchestrator, sess.subjectID) 37 38 return &chatcommands.Result{}, nil 39 + }) 40 + 41 + reg.Register("/skill", func(ctx context.Context, args string) (*chatcommands.Result, error) { 42 + output, err := skillsutil.RenderSkillStatus(skillsutil.SkillsConfigFromRunCmd(sess.cmd), sess.loadedSkills) 43 + if err != nil { 44 + return nil, err 45 + } 46 + return &chatcommands.Result{Reply: output}, nil 38 47 }) 39 48 40 49 reg.Register("/help", chatcommands.HelpHandler(reg, "Available commands:")) ··· 140 149 141 150 // handleHelp prints the help text. 142 151 func handleHelp(writer io.Writer) { 143 - _, _ = fmt.Fprintln(writer, "Commands: /exit, /quit, /reset, /memory, /remember <content>, /model, /workspace, /init, /update, /help") 152 + _, _ = fmt.Fprintln(writer, "Commands: /exit, /quit, /reset, /memory, /remember <content>, /skill, /model, /workspace, /init, /update, /help") 144 153 } 145 154 146 155 func chatBuiltinCommandsBlock() string { ··· 150 159 "- `/reset` — reset the current conversation (clear history, keep memory)\n" + 151 160 "- `/memory` — display the current project memory\n" + 152 161 "- `/remember <content>` — add a long-term memory item for the current project\n" + 162 + "- `/skill` — show loaded and not loaded skills\n" + 153 163 "- `/model` — inspect or change the current model selection for this session\n" + 154 164 "- `/workspace` — show the current workspace attachment\n" + 155 165 "- `/workspace attach <dir>` — attach or replace the current workspace directory\n" +
+1
cmd/mistermorph/chatcmd/repl.go
··· 29 29 readline.PcItem("/reset"), 30 30 readline.PcItem("/memory"), 31 31 readline.PcItem("/remember "), 32 + readline.PcItem("/skill"), 32 33 readline.PcItem("/init"), 33 34 readline.PcItem("/update"), 34 35 readline.PcItem("/model"),
+3 -1
cmd/mistermorph/chatcmd/session.go
··· 64 64 makeEngine func(*tools.Registry, llm.Client, string) *agent.Engine 65 65 basePromptSpec agent.PromptSpec 66 66 promptSpec agent.PromptSpec 67 + loadedSkills []string 67 68 timeout time.Duration 68 69 writer io.Writer 69 70 uiMu sync.Mutex ··· 358 359 promptCtx, cancel := context.WithTimeout(context.Background(), timeout) 359 360 defer cancel() 360 361 361 - promptSpec, _, err := skillsutil.PromptSpecWithSkills(promptCtx, logger, logOpts, "Interactive chat session", client, strings.TrimSpace(mainCfg.Model), skillsutil.SkillsConfigFromRunCmd(cmd)) 362 + promptSpec, loadedSkills, err := skillsutil.PromptSpecWithSkills(promptCtx, logger, logOpts, "Interactive chat session", client, strings.TrimSpace(mainCfg.Model), skillsutil.SkillsConfigFromRunCmd(cmd)) 362 363 if err != nil { 363 364 return nil, err 364 365 } ··· 561 562 workspaceDir: workspaceDir, 562 563 basePromptSpec: promptSpec, 563 564 promptSpec: promptSpec, 565 + loadedSkills: loadedSkills, 564 566 timeout: timeout, 565 567 fileSnapshots: make(map[string]string), 566 568 }
+26 -3
cmd/mistermorph/consolecmd/local_runtime.go
··· 986 986 Ref: "web/console", 987 987 }) 988 988 task := strings.TrimSpace(req.Task) 989 - if output, handled := r.handleConsoleHelpCommand(task); handled { 989 + if output, handled := r.handleConsoleHelpCommand(generation.reader, task); handled { 990 + resp, err := r.submitSyntheticTask(generation, task, output, timeout, strings.TrimSpace(req.TopicID), strings.TrimSpace(req.TopicTitle), trigger) 991 + if err == nil { 992 + releaseGeneration = false 993 + } 994 + return resp, err 995 + } 996 + if output, handled := r.handleConsoleSkillCommand(generation.reader, task); handled { 990 997 resp, err := r.submitSyntheticTask(generation, task, output, timeout, strings.TrimSpace(req.TopicID), strings.TrimSpace(req.TopicTitle), trigger) 991 998 if err == nil { 992 999 releaseGeneration = false ··· 1023 1030 return resp, err 1024 1031 } 1025 1032 1026 - func (r *consoleLocalRuntime) handleConsoleHelpCommand(task string) (string, bool) { 1033 + func (r *consoleLocalRuntime) handleConsoleHelpCommand(reader *viper.Viper, task string) (string, bool) { 1027 1034 cmdWord, _ := chatcommands.ParseCommand(task) 1028 1035 if chatcommands.NormalizeCommand(cmdWord) != "/help" { 1029 1036 return "", false 1030 1037 } 1031 - reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{}) 1038 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 1039 + SkillCommand: func() (string, error) { 1040 + return skillsutil.RenderSkillStatus(skillsutil.SkillsConfigFromReader(reader), nil) 1041 + }, 1042 + }) 1032 1043 result, err := chatcommands.HelpHandler(reg, "Available commands:")(context.Background(), "") 1033 1044 if err != nil { 1034 1045 return "error: " + strings.TrimSpace(err.Error()), true ··· 1037 1048 return "", true 1038 1049 } 1039 1050 return result.Reply, true 1051 + } 1052 + 1053 + func (r *consoleLocalRuntime) handleConsoleSkillCommand(reader *viper.Viper, task string) (string, bool) { 1054 + cmdWord, _ := chatcommands.ParseCommand(task) 1055 + if chatcommands.NormalizeCommand(cmdWord) != "/skill" { 1056 + return "", false 1057 + } 1058 + output, err := skillsutil.RenderSkillStatus(skillsutil.SkillsConfigFromReader(reader), nil) 1059 + if err != nil { 1060 + return "error: " + strings.TrimSpace(err.Error()), true 1061 + } 1062 + return output, true 1040 1063 } 1041 1064 1042 1065 func (r *consoleLocalRuntime) handleConsoleWorkspaceCommand(generation *consoleLocalRuntimeGeneration, req daemonruntime.SubmitTaskRequest, timeout time.Duration, trigger daemonruntime.TaskTrigger) (daemonruntime.SubmitTaskResponse, bool, error) {
+49 -1
cmd/mistermorph/consolecmd/local_runtime_test.go
··· 723 723 result, _ := task.Result.(map[string]any) 724 724 final, _ := result["final"].(map[string]any) 725 725 output := strings.TrimSpace(fmt.Sprint(final["output"])) 726 - for _, want := range []string{"/help", "/model", "/workspace"} { 726 + for _, want := range []string{"/help", "/model", "/skill", "/workspace"} { 727 + if !strings.Contains(output, want) { 728 + t.Fatalf("final.output missing %q: %q", want, output) 729 + } 730 + } 731 + } 732 + 733 + func TestConsoleLocalRuntimeSubmitTaskHandlesSkillCommand(t *testing.T) { 734 + prevStateDir := viper.GetString("file_state_dir") 735 + prevSkillsDirName := viper.GetString("skills.dir_name") 736 + defer func() { 737 + viper.Set("file_state_dir", prevStateDir) 738 + viper.Set("skills.dir_name", prevSkillsDirName) 739 + }() 740 + viper.Set("file_state_dir", t.TempDir()) 741 + viper.Set("skills.dir_name", "skills") 742 + 743 + store, err := daemonruntime.NewConsoleFileStore(daemonruntime.ConsoleFileStoreOptions{ 744 + HeartbeatTopicID: "_heartbeat", 745 + Persist: false, 746 + }) 747 + if err != nil { 748 + t.Fatalf("NewConsoleFileStore() error = %v", err) 749 + } 750 + reader := viper.New() 751 + reader.Set("skills.enabled", true) 752 + generation := &consoleLocalRuntimeGeneration{reader: reader} 753 + rt := &consoleLocalRuntime{ 754 + store: store, 755 + generation: generation, 756 + } 757 + 758 + resp, err := rt.submitTask(context.Background(), daemonruntime.SubmitTaskRequest{ 759 + Task: "/skill", 760 + }) 761 + if err != nil { 762 + t.Fatalf("submitTask() error = %v", err) 763 + } 764 + if resp.Status != daemonruntime.TaskDone { 765 + t.Fatalf("resp.Status = %q, want %q", resp.Status, daemonruntime.TaskDone) 766 + } 767 + task, ok := store.Get(resp.ID) 768 + if !ok || task == nil { 769 + t.Fatalf("store.Get(%q) missing", resp.ID) 770 + } 771 + result, _ := task.Result.(map[string]any) 772 + final, _ := result["final"].(map[string]any) 773 + output := strings.TrimSpace(fmt.Sprint(final["output"])) 774 + for _, want := range []string{"Skills: enabled", "Loaded: none", "No skills discovered."} { 727 775 if !strings.Contains(output, want) { 728 776 t.Fatalf("final.output missing %q: %q", want, output) 729 777 }
+6
cmd/mistermorph/consolecmd/managed_runtime.go
··· 284 284 HandleModelCommand: func(text string) (string, bool, error) { 285 285 return llmselect.ExecuteCommandText(runtimeValues, llmselect.ProcessStore(), text) 286 286 }, 287 + HandleSkillCommand: func(currentLoaded []string) (string, error) { 288 + return skillsutil.RenderSkillStatus(skillsutil.SkillsConfigFromReader(reader), currentLoaded) 289 + }, 287 290 } 288 291 return func(ctx context.Context) error { 289 292 return telegramruntime.Run(ctx, runtimeDeps, runOpts) ··· 312 315 CommonDependencies: deps, 313 316 HandleModelCommand: func(text string) (string, bool, error) { 314 317 return llmselect.ExecuteCommandText(runtimeValues, llmselect.ProcessStore(), text) 318 + }, 319 + HandleSkillCommand: func(currentLoaded []string) (string, error) { 320 + return skillsutil.RenderSkillStatus(skillsutil.SkillsConfigFromReader(reader), currentLoaded) 315 321 }, 316 322 } 317 323 return func(ctx context.Context) error {
+1
cmd/mistermorph/larkcmd/command.go
··· 90 90 PromptAugment: d.PromptAugment, 91 91 }, 92 92 HandleModelCommand: d.HandleModelCommand, 93 + HandleSkillCommand: d.HandleSkillCommand, 93 94 } 94 95 }
+1
cmd/mistermorph/larkcmd/deps.go
··· 9 9 type Dependencies struct { 10 10 heartbeatruntime.Dependencies 11 11 HandleModelCommand larkruntime.HandleModelCommandFunc 12 + HandleSkillCommand larkruntime.HandleSkillCommandFunc 12 13 }
+1
cmd/mistermorph/linecmd/command.go
··· 86 86 PromptAugment: d.PromptAugment, 87 87 }, 88 88 HandleModelCommand: d.HandleModelCommand, 89 + HandleSkillCommand: d.HandleSkillCommand, 89 90 } 90 91 }
+1
cmd/mistermorph/linecmd/deps.go
··· 9 9 type Dependencies struct { 10 10 heartbeatruntime.Dependencies 11 11 HandleModelCommand lineruntime.HandleModelCommandFunc 12 + HandleSkillCommand lineruntime.HandleSkillCommandFunc 12 13 }
+8
cmd/mistermorph/root.go
··· 120 120 HandleModelCommand: func(text string) (string, bool, error) { 121 121 return llmselect.ExecuteCommandText(telegramLLM.Values(), llmselect.ProcessStore(), text) 122 122 }, 123 + HandleSkillCommand: telegramSkills.Status, 123 124 })) 124 125 125 126 slackLLM := newLLMRuntimeResolver() ··· 152 153 HandleModelCommand: func(text string) (string, bool, error) { 153 154 return llmselect.ExecuteCommandText(slackLLM.Values(), llmselect.ProcessStore(), text) 154 155 }, 156 + HandleSkillCommand: slackSkills.Status, 155 157 })) 156 158 cmd.AddCommand(linecmd.NewCommand(linecmd.Dependencies{ 157 159 Dependencies: heartbeatruntime.Dependencies{ ··· 176 178 HandleModelCommand: func(text string) (string, bool, error) { 177 179 return llmselect.ExecuteCommandText(lineLLM.Values(), llmselect.ProcessStore(), text) 178 180 }, 181 + HandleSkillCommand: lineSkills.Status, 179 182 })) 180 183 cmd.AddCommand(larkcmd.NewCommand(larkcmd.Dependencies{ 181 184 Dependencies: heartbeatruntime.Dependencies{ ··· 200 203 HandleModelCommand: func(text string) (string, bool, error) { 201 204 return llmselect.ExecuteCommandText(larkLLM.Values(), llmselect.ProcessStore(), text) 202 205 }, 206 + HandleSkillCommand: larkSkills.Status, 203 207 })) 204 208 cmd.AddCommand(newToolsCmd(registryResolver.Registry)) 205 209 cmd.AddCommand(newAuthCmd()) ··· 326 330 cfg.Roots = append([]string(nil), cfg.Roots...) 327 331 cfg.Requested = append([]string(nil), cfg.Requested...) 328 332 return cfg 333 + } 334 + 335 + func (r *skillsRuntimeResolver) Status(currentLoaded []string) (string, error) { 336 + return skillsutil.RenderSkillStatus(r.Config(), currentLoaded) 329 337 } 330 338 331 339 type registryRuntimeResolver struct {
+1
cmd/mistermorph/slackcmd/command.go
··· 134 134 PromptSpec: d.PromptSpec, 135 135 }, 136 136 HandleModelCommand: d.HandleModelCommand, 137 + HandleSkillCommand: d.HandleSkillCommand, 137 138 } 138 139 } 139 140
+1
cmd/mistermorph/slackcmd/deps.go
··· 9 9 type Dependencies struct { 10 10 heartbeatruntime.Dependencies 11 11 HandleModelCommand slackruntime.HandleModelCommandFunc 12 + HandleSkillCommand slackruntime.HandleSkillCommandFunc 12 13 }
+1
cmd/mistermorph/telegramcmd/command.go
··· 139 139 PromptSpec: d.PromptSpec, 140 140 }, 141 141 HandleModelCommand: d.HandleModelCommand, 142 + HandleSkillCommand: d.HandleSkillCommand, 142 143 } 143 144 } 144 145
+1
cmd/mistermorph/telegramcmd/deps.go
··· 9 9 type Dependencies struct { 10 10 heartbeatruntime.Dependencies 11 11 HandleModelCommand telegramruntime.HandleModelCommandFunc 12 + HandleSkillCommand telegramruntime.HandleSkillCommandFunc 12 13 }
+12 -1
internal/channelruntime/lark/model_command.go
··· 11 11 "github.com/quailyquaily/mistermorph/internal/workspace" 12 12 ) 13 13 14 - func maybeHandleLarkCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, inbound larkbus.InboundMessage) (bool, error) { 14 + func maybeHandleLarkCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, inbound larkbus.InboundMessage, currentSkills []string) (bool, error) { 15 15 reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 16 16 ModelCommand: d.HandleModelCommand, 17 + SkillCommand: skillCommandForRuntime(d.HandleSkillCommand, currentSkills), 17 18 WorkspaceStore: store, 18 19 WorkspaceKey: conversationKey, 19 20 }) ··· 37 38 _, publishErr := publishLarkBusOutbound(ctx, inprocBus, inbound.ChatID, output, inbound.MessageID, correlationID) 38 39 return true, publishErr 39 40 } 41 + 42 + func skillCommandForRuntime(fn HandleSkillCommandFunc, currentSkills []string) chatcommands.SkillCommandFunc { 43 + if fn == nil { 44 + return nil 45 + } 46 + snapshot := append([]string(nil), currentSkills...) 47 + return func() (string, error) { 48 + return fn(snapshot) 49 + } 50 + }
+2
internal/channelruntime/lark/run.go
··· 9 9 ) 10 10 11 11 type HandleModelCommandFunc func(text string) (string, bool, error) 12 + type HandleSkillCommandFunc func(currentLoaded []string) (string, error) 12 13 13 14 type Dependencies struct { 14 15 depsutil.CommonDependencies 15 16 HandleModelCommand HandleModelCommandFunc 17 + HandleSkillCommand HandleSkillCommandFunc 16 18 } 17 19 18 20 // Hooks is intentionally minimal in the bootstrap phase.
+4 -1
internal/channelruntime/lark/runtime.go
··· 325 325 if text == "" { 326 326 return fmt.Errorf("lark inbound text is required") 327 327 } 328 - if handledCommand, cmdErr := maybeHandleLarkCommand(ctx, d, inprocBus, workspaceStore, msg.ConversationKey, inbound); handledCommand { 328 + mu.Lock() 329 + currentSkills := append([]string(nil), stickySkillsByConv[msg.ConversationKey]...) 330 + mu.Unlock() 331 + if handledCommand, cmdErr := maybeHandleLarkCommand(ctx, d, inprocBus, workspaceStore, msg.ConversationKey, inbound, currentSkills); handledCommand { 329 332 return cmdErr 330 333 } 331 334 if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "group") {
+12 -1
internal/channelruntime/line/model_command.go
··· 11 11 "github.com/quailyquaily/mistermorph/internal/workspace" 12 12 ) 13 13 14 - func maybeHandleLineCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, inbound linebus.InboundMessage) (bool, error) { 14 + func maybeHandleLineCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, inbound linebus.InboundMessage, currentSkills []string) (bool, error) { 15 15 reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 16 16 ModelCommand: d.HandleModelCommand, 17 + SkillCommand: skillCommandForRuntime(d.HandleSkillCommand, currentSkills), 17 18 WorkspaceStore: store, 18 19 WorkspaceKey: conversationKey, 19 20 }) ··· 37 38 _, publishErr := publishLineBusOutbound(ctx, inprocBus, inbound.ChatID, output, inbound.ReplyToken, correlationID) 38 39 return true, publishErr 39 40 } 41 + 42 + func skillCommandForRuntime(fn HandleSkillCommandFunc, currentSkills []string) chatcommands.SkillCommandFunc { 43 + if fn == nil { 44 + return nil 45 + } 46 + snapshot := append([]string(nil), currentSkills...) 47 + return func() (string, error) { 48 + return fn(snapshot) 49 + } 50 + }
+2
internal/channelruntime/line/run.go
··· 9 9 ) 10 10 11 11 type HandleModelCommandFunc func(text string) (string, bool, error) 12 + type HandleSkillCommandFunc func(currentLoaded []string) (string, error) 12 13 13 14 type Dependencies struct { 14 15 depsutil.CommonDependencies 15 16 HandleModelCommand HandleModelCommandFunc 17 + HandleSkillCommand HandleSkillCommandFunc 16 18 } 17 19 18 20 // Hooks is intentionally minimal in the bootstrap phase.
+4 -1
internal/channelruntime/line/runtime.go
··· 369 369 if text == "" { 370 370 return fmt.Errorf("line inbound text is required") 371 371 } 372 - if handledCommand, cmdErr := maybeHandleLineCommand(ctx, d, inprocBus, workspaceStore, msg.ConversationKey, inbound); handledCommand { 372 + mu.Lock() 373 + currentSkills := append([]string(nil), stickySkillsByConv[msg.ConversationKey]...) 374 + mu.Unlock() 375 + if handledCommand, cmdErr := maybeHandleLineCommand(ctx, d, inprocBus, workspaceStore, msg.ConversationKey, inbound, currentSkills); handledCommand { 373 376 return cmdErr 374 377 } 375 378 if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "group") {
+2
internal/channelruntime/slack/deps.go
··· 3 3 import "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 4 4 5 5 type HandleModelCommandFunc func(text string) (string, bool, error) 6 + type HandleSkillCommandFunc func(currentLoaded []string) (string, error) 6 7 7 8 type Dependencies struct { 8 9 depsutil.CommonDependencies 9 10 HandleModelCommand HandleModelCommandFunc 11 + HandleSkillCommand HandleSkillCommandFunc 10 12 }
+12 -1
internal/channelruntime/slack/model_command.go
··· 10 10 "github.com/quailyquaily/mistermorph/internal/workspace" 11 11 ) 12 12 13 - func maybeHandleSlackCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, event slackInboundEvent, botUserID string) (bool, error) { 13 + func maybeHandleSlackCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, event slackInboundEvent, botUserID string, currentSkills []string) (bool, error) { 14 14 if isSlackGroupChat(event.ChatType) && !slackCommandExplicitlyAddressed(event.Text, botUserID) { 15 15 return false, nil 16 16 } 17 17 text := normalizeSlackCommandText(event.Text, botUserID) 18 18 reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 19 19 ModelCommand: d.HandleModelCommand, 20 + SkillCommand: skillCommandForRuntime(d.HandleSkillCommand, currentSkills), 20 21 WorkspaceStore: store, 21 22 WorkspaceKey: conversationKey, 22 23 }) ··· 47 48 correlationID, 48 49 ) 49 50 return true, publishErr 51 + } 52 + 53 + func skillCommandForRuntime(fn HandleSkillCommandFunc, currentSkills []string) chatcommands.SkillCommandFunc { 54 + if fn == nil { 55 + return nil 56 + } 57 + snapshot := append([]string(nil), currentSkills...) 58 + return func() (string, error) { 59 + return fn(snapshot) 60 + } 50 61 } 51 62 52 63 func normalizeSlackCommandText(text string, botUserID string) string {
+4 -1
internal/channelruntime/slack/runtime.go
··· 876 876 event.Username = username 877 877 event.DisplayName = displayName 878 878 event.Text = slackImageFallbackText(event.Text, taskRuntimeOpts.ImageRecognitionEnabled, len(event.ImageFiles)) 879 - handledCommand, cmdErr := maybeHandleSlackCommand(context.Background(), d, inprocBus, workspaceStore, conversationKey, event, botUserID) 879 + mu.Lock() 880 + currentSkills := append([]string(nil), stickySkillsByConv[historyScopeKey]...) 881 + mu.Unlock() 882 + handledCommand, cmdErr := maybeHandleSlackCommand(context.Background(), d, inprocBus, workspaceStore, conversationKey, event, botUserID, currentSkills) 880 883 if cmdErr != nil { 881 884 logger.Warn("slack_command_error", 882 885 "conversation_key", conversationKey,
+2
internal/channelruntime/telegram/deps.go
··· 3 3 import "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 4 4 5 5 type HandleModelCommandFunc func(text string) (string, bool, error) 6 + type HandleSkillCommandFunc func(currentLoaded []string) (string, error) 6 7 7 8 type Dependencies struct { 8 9 depsutil.CommonDependencies 9 10 HandleModelCommand HandleModelCommandFunc 11 + HandleSkillCommand HandleSkillCommandFunc 10 12 }
+12
internal/channelruntime/telegram/model_command.go
··· 24 24 return true 25 25 } 26 26 27 + func executeTelegramSkillCommand(d Dependencies, api *telegramAPI, chatID int64, currentSkills []string) bool { 28 + if d.HandleSkillCommand == nil { 29 + return false 30 + } 31 + output, err := d.HandleSkillCommand(append([]string(nil), currentSkills...)) 32 + if err != nil { 33 + output = "error: " + strings.TrimSpace(err.Error()) 34 + } 35 + _ = api.sendMessageHTML(context.Background(), chatID, htmlstd.EscapeString(output), true) 36 + return true 37 + } 38 + 27 39 func resolveTelegramMainForUse(rt *taskruntime.Runtime) (llm.Client, string, func(), error) { 28 40 route, err := rt.ResolveMainRouteForRun() 29 41 if err != nil {
+15 -1
internal/channelruntime/telegram/runtime.go
··· 1037 1037 switch normalizedCmd { 1038 1038 case "/help": 1039 1039 help := "Send a message and I will run it as an agent task.\n" + 1040 - "Commands: /model, /workspace, /reset, /id\n\n" + 1040 + "Commands: /model, /skill, /workspace, /reset, /id\n\n" + 1041 1041 "Group chats: reply to me, or mention @" + botUser + ".\n" + 1042 1042 "You can also send a file (document/photo). It will be downloaded under file_cache_dir/telegram/ and the agent can process it.\n" + 1043 1043 "Note: if Bot Privacy Mode is enabled, I may not receive normal group messages." ··· 1053 1053 continue 1054 1054 } 1055 1055 _ = api.sendMessageHTML(context.Background(), chatID, "error: "+htmlstd.EscapeString("missing llm profile command handler"), true) 1056 + continue 1057 + case "/skill": 1058 + if len(allowed) > 0 && !allowed[chatID] { 1059 + logger.Warn("telegram_unauthorized_chat", "chat_id", chatID) 1060 + sendTelegramUnauthorizedMessage(api, chatID, chatType) 1061 + continue 1062 + } 1063 + mu.Lock() 1064 + currentSkills := append([]string(nil), stickySkillsByChat[chatID]...) 1065 + mu.Unlock() 1066 + if executeTelegramSkillCommand(d, api, chatID, currentSkills) { 1067 + continue 1068 + } 1069 + _ = api.sendMessageHTML(context.Background(), chatID, "error: "+htmlstd.EscapeString("missing skill command handler"), true) 1056 1070 continue 1057 1071 case "/id": 1058 1072 _ = api.sendMessageHTML(context.Background(), chatID, fmt.Sprintf("chat_id=%d type=%s", chatID, chatType), true)
+24
internal/chatcommands/dispatcher_test.go
··· 162 162 t.Fatalf("unexpected reply: %#v", res) 163 163 } 164 164 } 165 + 166 + func TestRuntimeRegistryHandlesSkillCommand(t *testing.T) { 167 + reg := NewRuntimeRegistry(RuntimeRegistryOptions{ 168 + SkillCommand: func() (string, error) { 169 + return "skills ok", nil 170 + }, 171 + }) 172 + 173 + help, handled, err := reg.Dispatch(context.Background(), "/help") 174 + if err != nil { 175 + t.Fatalf("/help error = %v", err) 176 + } 177 + if !handled || help == nil || !strings.Contains(help.Reply, "/skill") { 178 + t.Fatalf("/help missing /skill: %#v handled=%v", help, handled) 179 + } 180 + 181 + res, handled, err := reg.Dispatch(context.Background(), "/skill") 182 + if err != nil { 183 + t.Fatalf("/skill error = %v", err) 184 + } 185 + if !handled || res == nil || res.Reply != "skills ok" { 186 + t.Fatalf("unexpected /skill result: %#v handled=%v", res, handled) 187 + } 188 + }
+16
internal/chatcommands/handlers.go
··· 63 63 return text 64 64 } 65 65 66 + // SkillCommandFunc returns a snapshot of the current skill loading state. 67 + type SkillCommandFunc = func() (output string, err error) 68 + 69 + func SkillCommandHandler(fn SkillCommandFunc) Handler { 70 + return func(ctx context.Context, args string) (*Result, error) { 71 + if fn == nil { 72 + return nil, fmt.Errorf("missing skill command handler") 73 + } 74 + output, err := fn() 75 + if err != nil { 76 + return nil, err 77 + } 78 + return &Result{Reply: output}, nil 79 + } 80 + } 81 + 66 82 // ModelHandler wraps the llmselect package so that /model commands can be 67 83 // handled uniformly across chat front-ends. 68 84 //
+4
internal/chatcommands/runtime.go
··· 8 8 9 9 type RuntimeRegistryOptions struct { 10 10 ModelCommand ModelCommandFunc 11 + SkillCommand SkillCommandFunc 11 12 WorkspaceStore *workspace.Store 12 13 WorkspaceKey string 13 14 HelpHeader string ··· 21 22 } 22 23 reg.Register("/help", HelpHandler(reg, header)) 23 24 reg.Register("/model", ModelCommandHandler(opts.ModelCommand)) 25 + if opts.SkillCommand != nil { 26 + reg.Register("/skill", SkillCommandHandler(opts.SkillCommand)) 27 + } 24 28 reg.Register("/workspace", WorkspaceHandler(opts.WorkspaceStore, opts.WorkspaceKey)) 25 29 return reg 26 30 }
+136
internal/skillsutil/status.go
··· 1 + package skillsutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/quailyquaily/mistermorph/skills" 8 + ) 9 + 10 + type SkillStatus struct { 11 + Enabled bool 12 + Loaded []SkillStatusItem 13 + NotLoaded []SkillStatusItem 14 + Missing []string 15 + } 16 + 17 + type SkillStatusItem struct { 18 + ID string 19 + Name string 20 + } 21 + 22 + func BuildSkillStatus(cfg SkillsConfig, currentLoaded []string) (SkillStatus, error) { 23 + status := SkillStatus{Enabled: cfg.Enabled} 24 + discovered, err := skills.Discover(skills.DiscoverOptions{Roots: cfg.Roots}) 25 + if err != nil { 26 + return status, err 27 + } 28 + 29 + loadedIDs := map[string]bool{} 30 + if cfg.Enabled { 31 + requested := append([]string{}, cfg.Requested...) 32 + requested = append(requested, currentLoaded...) 33 + finalReq, loadAll := normalizeSkillStatusRequests(requested) 34 + if len(finalReq) == 0 { 35 + loadAll = true 36 + } 37 + if loadAll { 38 + for _, sk := range discovered { 39 + loadedIDs[strings.ToLower(strings.TrimSpace(sk.ID))] = true 40 + } 41 + } else { 42 + for _, query := range finalReq { 43 + sk, err := skills.Resolve(discovered, query) 44 + if err != nil { 45 + status.Missing = append(status.Missing, query) 46 + continue 47 + } 48 + loadedIDs[strings.ToLower(strings.TrimSpace(sk.ID))] = true 49 + } 50 + } 51 + } 52 + 53 + for _, sk := range discovered { 54 + item := SkillStatusItem{ 55 + ID: strings.TrimSpace(sk.ID), 56 + Name: strings.TrimSpace(sk.Name), 57 + } 58 + key := strings.ToLower(item.ID) 59 + if loadedIDs[key] { 60 + status.Loaded = append(status.Loaded, item) 61 + } else { 62 + status.NotLoaded = append(status.NotLoaded, item) 63 + } 64 + } 65 + return status, nil 66 + } 67 + 68 + func RenderSkillStatus(cfg SkillsConfig, currentLoaded []string) (string, error) { 69 + status, err := BuildSkillStatus(cfg, currentLoaded) 70 + if err != nil { 71 + return "", err 72 + } 73 + var b strings.Builder 74 + if status.Enabled { 75 + b.WriteString("Skills: enabled") 76 + } else { 77 + b.WriteString("Skills: disabled") 78 + } 79 + writeSkillStatusItems(&b, "Loaded", status.Loaded) 80 + writeSkillStatusItems(&b, "Not loaded", status.NotLoaded) 81 + if len(status.Missing) > 0 { 82 + b.WriteString("\nMissing requested:") 83 + for _, name := range status.Missing { 84 + b.WriteString("\n- ") 85 + b.WriteString(name) 86 + } 87 + } 88 + if len(status.Loaded) == 0 && len(status.NotLoaded) == 0 { 89 + b.WriteString("\nNo skills discovered.") 90 + } 91 + return b.String(), nil 92 + } 93 + 94 + func normalizeSkillStatusRequests(requested []string) ([]string, bool) { 95 + seen := map[string]bool{} 96 + var out []string 97 + loadAll := false 98 + for _, raw := range requested { 99 + item := strings.TrimSpace(raw) 100 + if item == "" { 101 + continue 102 + } 103 + if item == "*" { 104 + loadAll = true 105 + } 106 + key := strings.ToLower(item) 107 + if seen[key] { 108 + continue 109 + } 110 + seen[key] = true 111 + out = append(out, item) 112 + } 113 + return out, loadAll 114 + } 115 + 116 + func writeSkillStatusItems(b *strings.Builder, title string, items []SkillStatusItem) { 117 + if b == nil { 118 + return 119 + } 120 + if len(items) == 0 { 121 + fmt.Fprintf(b, "\n%s: none", title) 122 + return 123 + } 124 + fmt.Fprintf(b, "\n%s (%d):", title, len(items)) 125 + for _, item := range items { 126 + label := strings.TrimSpace(item.ID) 127 + if label == "" { 128 + label = strings.TrimSpace(item.Name) 129 + } 130 + if label == "" { 131 + continue 132 + } 133 + b.WriteString("\n- ") 134 + b.WriteString(label) 135 + } 136 + }
+64
internal/skillsutil/status_test.go
··· 1 + package skillsutil 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestRenderSkillStatusShowsLoadedAndNotLoaded(t *testing.T) { 9 + root := t.TempDir() 10 + writeSkill(t, root, "alpha") 11 + writeSkill(t, root, "beta") 12 + 13 + out, err := RenderSkillStatus(SkillsConfig{ 14 + Roots: []string{root}, 15 + Enabled: true, 16 + Requested: []string{"alpha"}, 17 + }, nil) 18 + if err != nil { 19 + t.Fatalf("RenderSkillStatus() error = %v", err) 20 + } 21 + for _, want := range []string{"Skills: enabled", "Loaded (1):", "- alpha", "Not loaded (1):", "- beta"} { 22 + if !strings.Contains(out, want) { 23 + t.Fatalf("status missing %q:\n%s", want, out) 24 + } 25 + } 26 + } 27 + 28 + func TestRenderSkillStatusUsesCurrentLoadedSkills(t *testing.T) { 29 + root := t.TempDir() 30 + writeSkill(t, root, "alpha") 31 + writeSkill(t, root, "beta") 32 + 33 + out, err := RenderSkillStatus(SkillsConfig{ 34 + Roots: []string{root}, 35 + Enabled: true, 36 + }, []string{"beta"}) 37 + if err != nil { 38 + t.Fatalf("RenderSkillStatus() error = %v", err) 39 + } 40 + if !strings.Contains(out, "Loaded (1):") || !strings.Contains(out, "- beta") { 41 + t.Fatalf("status missing current loaded skill:\n%s", out) 42 + } 43 + if !strings.Contains(out, "Not loaded (1):") || !strings.Contains(out, "- alpha") { 44 + t.Fatalf("status missing not loaded skill:\n%s", out) 45 + } 46 + } 47 + 48 + func TestRenderSkillStatusDisabled(t *testing.T) { 49 + root := t.TempDir() 50 + writeSkill(t, root, "alpha") 51 + 52 + out, err := RenderSkillStatus(SkillsConfig{ 53 + Roots: []string{root}, 54 + Enabled: false, 55 + }, nil) 56 + if err != nil { 57 + t.Fatalf("RenderSkillStatus() error = %v", err) 58 + } 59 + for _, want := range []string{"Skills: disabled", "Loaded: none", "Not loaded (1):", "- alpha"} { 60 + if !strings.Contains(out, want) { 61 + t.Fatalf("status missing %q:\n%s", want, out) 62 + } 63 + } 64 + }