From ca337cf9d159ad341ca2829295e7eae23f5b2522 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:02:42 +0900 Subject: [PATCH] feat: add /skill and /skills commands with history compaction /skills lists available skills (zero LLM cost). /skill [message] injects SKILL.md into context for reliable skill invocation. After the turn completes, only the skill name tag is kept in session history to avoid wasting the context window. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/context.go | 10 ++++ pkg/agent/loop.go | 98 ++++++++++++++++++++++++++++++- pkg/channels/telegram.go | 4 ++ pkg/channels/telegram_commands.go | 2 + 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index aed94257e..81898aeca 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -261,6 +261,16 @@ func (cb *ContextBuilder) loadSkills() string { return "# Skill Definitions\n\n" + content } +// LoadSkill loads a skill by name, returning its content (with frontmatter stripped) and whether it was found. +func (cb *ContextBuilder) LoadSkill(name string) (string, bool) { + return cb.skillsLoader.LoadSkill(name) +} + +// ListSkills returns all available skills from all tiers. +func (cb *ContextBuilder) ListSkills() []skills.SkillInfo { + return cb.skillsLoader.ListSkills() +} + // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { allSkills := cb.skillsLoader.ListSkills() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a5742e2ba..7d973013c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -51,6 +51,7 @@ type processOptions struct { Channel string // Target channel for tool execution ChatID string // Target chat ID for tool execution UserMessage string // User message content (may include prefix) + HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction) DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus @@ -306,6 +307,13 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.processSystemMessage(ctx, msg) } + // Expand /skill command: inject SKILL.md content into message, then continue to LLM + var skillCompact string + if expanded, compact, ok := al.expandSkillCommand(msg); ok { + msg.Content = expanded + skillCompact = compact + } + // Check for commands if response, handled := al.handleCommand(ctx, msg); handled { return response, nil @@ -344,6 +352,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, + HistoryMessage: skillCompact, DefaultResponse: "I've completed processing but have no response to give.", EnableSummary: true, SendResponse: false, @@ -438,8 +447,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt opts.ChatID, ) - // 3. Save user message to session - agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) + // 3. Save user message to session (use compact form if available) + historyMsg := opts.UserMessage + if opts.HistoryMessage != "" { + historyMsg = opts.HistoryMessage + } + agent.Sessions.AddMessage(opts.SessionKey, "user", historyMsg) // 4. Record user prompt for stats if al.stats != nil { @@ -1201,6 +1214,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) case "/session": return al.handleSessionCommand(args), true + + case "/skills": + return al.handleSkillsCommand(), true } return "", false @@ -1253,6 +1269,84 @@ func (al *AgentLoop) handleSessionCommand(args []string) string { ) } +// expandSkillCommand detects "/skill [message]" and returns: +// - expanded: full content with SKILL.md injected (for LLM) +// - compact: skill name tag + user message only (for history) +// - ok: whether expansion happened +func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { + content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "/skill ") { + return "", "", false + } + + // Parse: /skill [message] + rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7 + parts := strings.SplitN(rest, " ", 2) + if len(parts) == 0 || parts[0] == "" { + return "", "", false + } + + skillName := parts[0] + userMessage := "" + if len(parts) > 1 { + userMessage = strings.TrimSpace(parts[1]) + } + + agent := al.registry.GetDefaultAgent() + if agent == nil { + return "", "", false + } + + skillContent, found := agent.ContextBuilder.LoadSkill(skillName) + if !found { + return "", "", false + } + + tag := fmt.Sprintf("[Skill: %s]", skillName) + + // Build expanded message: skill instructions + user message (for LLM) + var sb strings.Builder + sb.WriteString(tag) + sb.WriteString("\n\n") + sb.WriteString(skillContent) + if userMessage != "" { + sb.WriteString("\n\n---\n\n") + sb.WriteString(userMessage) + } + + // Build compact form: skill name tag + user message only (for history) + compactForm := tag + if userMessage != "" { + compactForm = tag + "\n" + userMessage + } + + return sb.String(), compactForm, true +} + +// handleSkillsCommand lists all available skills. +func (al *AgentLoop) handleSkillsCommand() string { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return "No agent configured." + } + + skillsList := agent.ContextBuilder.ListSkills() + if len(skillsList) == 0 { + return "No skills available.\nAdd skills to your workspace/skills/ directory." + } + + var sb strings.Builder + sb.WriteString("Available Skills\n\n") + for _, s := range skillsList { + sb.WriteString(fmt.Sprintf(" %s (%s)\n", s.Name, s.Source)) + if s.Description != "" { + sb.WriteString(fmt.Sprintf(" %s\n", s.Description)) + } + } + sb.WriteString(fmt.Sprintf("\nUse: /skill [message]")) + return sb.String() +} + // extractPeer extracts the routing peer from inbound message metadata. func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { peerKind := msg.Metadata["peer_kind"] diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 9f5cbaada..130e1812c 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -130,6 +130,10 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.handleQuickCommand(ctx, message) }, th.CommandEqual("session")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.handleQuickCommand(ctx, message) + }, th.CommandEqual("skills")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go index 63e412a31..9dcf2523d 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram_commands.go @@ -42,6 +42,8 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error { /list [models|channels] - List available options /todo - Show your task list /session - Show usage statistics +/skills - List available skills +/skill [message] - Invoke a skill ` _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID},