feat(commands): refactor to /prefix registry, add /clear and /compact
- Migrate all : prefix commands (:cmd, :pico, :hipico, :edit, :usage, :help) to / prefix commands registered in the unified command registry - Remove handleExtensionCommand and handleModeCommand from loop.go - Add new commands: /clear (delete all chat history), /compact (summarize/compress history) - Add new commands: /cmd, /pico, /hipico, /edit, /usage as Definition in pkg/commands/ - Extend Runtime with session-mode callbacks (GetSessionMode, SetModeCmd, SetModePico), file editing (EditFile, GetWorkDir, GetWorkspace), token stats (GetTokenUsage), one-shot LLM query (RunOneShot), and session management (ClearSession, CompactSession) - All commands are auto-discoverable via /help and Telegram bot menu - Update editUsage() and handleEditCommand to use /edit prefix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ddb5b00c6
commit
03bc632b3c
8 changed files with 327 additions and 157 deletions
|
|
@ -634,11 +634,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
route, agent, routeErr := al.resolveMessageRoute(msg)
|
route, agent, routeErr := al.resolveMessageRoute(msg)
|
||||||
|
|
||||||
|
// Compute session key upfront so session-scoped commands (/cmd, /pico, /clear…)
|
||||||
|
// get the same key that the agent loop will use. Falls back to msg.SessionKey when
|
||||||
|
// routing fails, which is fine — context-dependent commands guard against nil agent.
|
||||||
|
sessionKey := resolveScopeKey(route, msg.SessionKey)
|
||||||
|
|
||||||
// Commands are checked before requiring a successful route.
|
// Commands are checked before requiring a successful route.
|
||||||
// Global commands (/help, /show, /switch) work even when routing fails;
|
// Global commands (/help, /show, /switch) work even when routing fails;
|
||||||
// context-dependent commands check their own Runtime fields and report
|
// context-dependent commands check their own Runtime fields and report
|
||||||
// "unavailable" when the required capability is nil.
|
// "unavailable" when the required capability is nil.
|
||||||
if response, handled := al.handleCommand(ctx, msg, agent); handled {
|
if response, handled := al.handleCommand(ctx, msg, agent, sessionKey); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -653,49 +658,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
|
||||||
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
|
||||||
sessionKey := scopeKey
|
|
||||||
|
|
||||||
logger.InfoCF("agent", "Routed message",
|
logger.InfoCF("agent", "Routed message",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"scope_key": scopeKey,
|
"scope_key": sessionKey,
|
||||||
"session_key": sessionKey,
|
"session_key": sessionKey,
|
||||||
"matched_by": route.MatchedBy,
|
"matched_by": route.MatchedBy,
|
||||||
"route_agent": route.AgentID,
|
"route_agent": route.AgentID,
|
||||||
"route_channel": route.Channel,
|
"route_channel": route.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle mode-switching commands (:cmd, :pico, :hipico)
|
|
||||||
content := strings.TrimSpace(msg.Content)
|
|
||||||
if strings.HasPrefix(content, ":") {
|
|
||||||
if response, handled := al.handleModeCommand(content, sessionKey, agent); handled {
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
// :hipico <msg> falls through here — one-shot LLM call, stays in modeCmd
|
|
||||||
if strings.HasPrefix(content, ":hipico") {
|
|
||||||
userMessage := strings.TrimSpace(strings.TrimPrefix(content, ":hipico"))
|
|
||||||
workDir := al.getSessionWorkDir(sessionKey)
|
|
||||||
if workDir == "" {
|
|
||||||
workDir = agent.Workspace
|
|
||||||
}
|
|
||||||
hipicoSessionKey := sessionKey + ":hipico"
|
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
|
||||||
SessionKey: hipicoSessionKey,
|
|
||||||
Channel: msg.Channel,
|
|
||||||
ChatID: msg.ChatID,
|
|
||||||
UserMessage: userMessage,
|
|
||||||
Media: msg.Media,
|
|
||||||
DefaultResponse: defaultResponse,
|
|
||||||
EnableSummary: false,
|
|
||||||
SendResponse: false,
|
|
||||||
WorkingDir: workDir,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch based on current session mode
|
// Dispatch based on current session mode
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
switch al.getSessionMode(sessionKey) {
|
switch al.getSessionMode(sessionKey) {
|
||||||
case modeCmd:
|
case modeCmd:
|
||||||
return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID)
|
return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID)
|
||||||
|
|
@ -1712,14 +1686,8 @@ func (al *AgentLoop) handleCommand(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg bus.InboundMessage,
|
msg bus.InboundMessage,
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
sessionKey string,
|
||||||
) (string, bool) {
|
) (string, bool) {
|
||||||
content := strings.TrimSpace(msg.Content)
|
|
||||||
|
|
||||||
// Handle : prefixed extension commands (work across all channels)
|
|
||||||
if strings.HasPrefix(content, ":") {
|
|
||||||
return al.handleExtensionCommand(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !commands.HasCommandPrefix(msg.Content) {
|
if !commands.HasCommandPrefix(msg.Content) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
@ -1728,7 +1696,7 @@ func (al *AgentLoop) handleCommand(
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
rt := al.buildCommandsRuntime(agent)
|
rt := al.buildCommandsRuntime(agent, sessionKey, msg)
|
||||||
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
||||||
|
|
||||||
var commandReply string
|
var commandReply string
|
||||||
|
|
@ -1757,7 +1725,7 @@ func (al *AgentLoop) handleCommand(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime {
|
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey string, msg bus.InboundMessage) *commands.Runtime {
|
||||||
rt := &commands.Runtime{
|
rt := &commands.Runtime{
|
||||||
Config: al.cfg,
|
Config: al.cfg,
|
||||||
ListAgentIDs: al.registry.ListAgentIDs,
|
ListAgentIDs: al.registry.ListAgentIDs,
|
||||||
|
|
@ -1777,7 +1745,107 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtim
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Session mode (session-scoped)
|
||||||
|
GetSessionMode: func() string {
|
||||||
|
if al.getSessionMode(sessionKey) == modeCmd {
|
||||||
|
return "cmd"
|
||||||
}
|
}
|
||||||
|
return "pico"
|
||||||
|
},
|
||||||
|
SetModeCmd: func() string {
|
||||||
|
if agent == nil {
|
||||||
|
return "Command unavailable in current context."
|
||||||
|
}
|
||||||
|
al.setSessionMode(sessionKey, modeCmd)
|
||||||
|
workDir := al.getSessionWorkDir(sessionKey)
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = agent.Workspace
|
||||||
|
al.setSessionWorkDir(sessionKey, workDir)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("```\n%s$\n```\nType `/pico` to return to chat mode.", shortenHomePath(workDir))
|
||||||
|
},
|
||||||
|
SetModePico: func() string {
|
||||||
|
al.setSessionMode(sessionKey, modePico)
|
||||||
|
return "Switched to chat mode. Type /cmd to enter command mode."
|
||||||
|
},
|
||||||
|
GetWorkDir: func() string {
|
||||||
|
if agent == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
workDir := al.getSessionWorkDir(sessionKey)
|
||||||
|
if workDir == "" {
|
||||||
|
return agent.Workspace
|
||||||
|
}
|
||||||
|
return workDir
|
||||||
|
},
|
||||||
|
GetWorkspace: func() string {
|
||||||
|
if agent == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return agent.Workspace
|
||||||
|
},
|
||||||
|
|
||||||
|
// File editing — delegates to handleEditCommand with proper path resolution
|
||||||
|
EditFile: func(content string) string {
|
||||||
|
if agent == nil {
|
||||||
|
return "Command unavailable in current context."
|
||||||
|
}
|
||||||
|
workDir := al.getSessionWorkDir(sessionKey)
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = agent.Workspace
|
||||||
|
}
|
||||||
|
return al.handleEditCommand(content, workDir, agent.Workspace)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Token usage stats
|
||||||
|
GetTokenUsage: func() (int64, int64, int64) {
|
||||||
|
if agent == nil {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
return agent.TotalPromptTokens.Load(), agent.TotalCompletionTokens.Load(), agent.TotalRequests.Load()
|
||||||
|
},
|
||||||
|
|
||||||
|
// One-shot AI query for /hipico (stays in modeCmd, separate session key)
|
||||||
|
RunOneShot: func(ctx context.Context, message string) (string, error) {
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no agent available")
|
||||||
|
}
|
||||||
|
workDir := al.getSessionWorkDir(sessionKey)
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = agent.Workspace
|
||||||
|
}
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
SessionKey: sessionKey + ":hipico",
|
||||||
|
Channel: msg.Channel,
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
UserMessage: message,
|
||||||
|
Media: msg.Media,
|
||||||
|
DefaultResponse: defaultResponse,
|
||||||
|
EnableSummary: false,
|
||||||
|
SendResponse: false,
|
||||||
|
WorkingDir: workDir,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
ClearSession: func() error {
|
||||||
|
if agent == nil {
|
||||||
|
return fmt.Errorf("no agent available")
|
||||||
|
}
|
||||||
|
agent.Sessions.TruncateHistory(sessionKey, 0)
|
||||||
|
agent.Sessions.SetSummary(sessionKey, "")
|
||||||
|
return agent.Sessions.Save(sessionKey)
|
||||||
|
},
|
||||||
|
CompactSession: func() error {
|
||||||
|
if agent == nil {
|
||||||
|
return fmt.Errorf("no agent available")
|
||||||
|
}
|
||||||
|
al.summarizeSession(agent, sessionKey)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if agent != nil {
|
if agent != nil {
|
||||||
rt.GetModelInfo = func() (string, string) {
|
rt.GetModelInfo = func() (string, string) {
|
||||||
return agent.Model, al.cfg.Agents.Defaults.Provider
|
return agent.Model, al.cfg.Agents.Defaults.Provider
|
||||||
|
|
@ -1798,98 +1866,6 @@ func mapCommandError(result commands.ExecuteResult) string {
|
||||||
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleExtensionCommand handles : prefixed commands that work across all channels.
|
|
||||||
func (al *AgentLoop) handleExtensionCommand(content string) (string, bool) {
|
|
||||||
parts := strings.Fields(content)
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := parts[0]
|
|
||||||
|
|
||||||
switch cmd {
|
|
||||||
case ":cmd", ":pico", ":hipico", ":edit":
|
|
||||||
// Pass through to processMessage for mode handling (needs sessionKey from routing)
|
|
||||||
return "", false
|
|
||||||
|
|
||||||
case ":help":
|
|
||||||
return `:help - Show this help message
|
|
||||||
:usage - Show model info and token usage
|
|
||||||
:cmd - Switch to command mode (execute shell commands)
|
|
||||||
:pico - Switch to chat mode (default, AI conversation)
|
|
||||||
:hipico <msg> - Ask AI for help (from command mode, one-shot)
|
|
||||||
:edit <file> - View/edit files (cmd mode)`, true
|
|
||||||
|
|
||||||
case ":usage":
|
|
||||||
agent := al.registry.GetDefaultAgent()
|
|
||||||
if agent == nil {
|
|
||||||
return "No agent available.", true
|
|
||||||
}
|
|
||||||
promptTokens := agent.TotalPromptTokens.Load()
|
|
||||||
completionTokens := agent.TotalCompletionTokens.Load()
|
|
||||||
return fmt.Sprintf(`Model: %s
|
|
||||||
Max tokens: %d
|
|
||||||
Temperature: %.1f
|
|
||||||
|
|
||||||
Token usage (this session):
|
|
||||||
Prompt tokens: %d
|
|
||||||
Completion tokens: %d
|
|
||||||
Total tokens: %d
|
|
||||||
Requests: %d`,
|
|
||||||
agent.Model,
|
|
||||||
agent.MaxTokens,
|
|
||||||
agent.Temperature,
|
|
||||||
promptTokens,
|
|
||||||
completionTokens,
|
|
||||||
promptTokens+completionTokens,
|
|
||||||
agent.TotalRequests.Load(),
|
|
||||||
), true
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Don't intercept unrecognized : prefixed messages (e.g. :) :D :thinking:)
|
|
||||||
// Let them pass through as normal chat messages
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleModeCommand processes mode-switching commands (:cmd, :pico, :hipico).
|
|
||||||
// Returns (response, handled). If handled is true, the caller should return the response directly.
|
|
||||||
// For :hipico with a message, it returns ("", false) so processMessage continues with a one-shot LLM call.
|
|
||||||
func (al *AgentLoop) handleModeCommand(content, sessionKey string, agent *AgentInstance) (string, bool) {
|
|
||||||
parts := strings.Fields(content)
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := parts[0]
|
|
||||||
|
|
||||||
switch cmd {
|
|
||||||
case ":cmd":
|
|
||||||
al.setSessionMode(sessionKey, modeCmd)
|
|
||||||
workDir := al.getSessionWorkDir(sessionKey)
|
|
||||||
if workDir == "" {
|
|
||||||
workDir = agent.Workspace
|
|
||||||
al.setSessionWorkDir(sessionKey, workDir)
|
|
||||||
}
|
|
||||||
displayDir := shortenHomePath(workDir)
|
|
||||||
return fmt.Sprintf("```\n%s$\n```\nType `:pico` to return to chat mode.", displayDir), true
|
|
||||||
|
|
||||||
case ":pico":
|
|
||||||
al.setSessionMode(sessionKey, modePico)
|
|
||||||
return "Switched to chat mode. Type :cmd to enter command mode.", true
|
|
||||||
|
|
||||||
case ":hipico":
|
|
||||||
msg := strings.TrimSpace(strings.TrimPrefix(content, ":hipico"))
|
|
||||||
if msg == "" {
|
|
||||||
return "Usage: :hipico <message>\nExample: :hipico check the log files for errors", true
|
|
||||||
}
|
|
||||||
// Stay in modeCmd, just flag for one-shot LLM call — processMessage handles it
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeCmdMode executes a shell command in command mode via ExecTool.
|
// executeCmdMode executes a shell command in command mode via ExecTool.
|
||||||
// Output is formatted as a console code block for channel display.
|
// Output is formatted as a console code block for channel display.
|
||||||
func (al *AgentLoop) executeCmdMode(
|
func (al *AgentLoop) executeCmdMode(
|
||||||
|
|
@ -1907,15 +1883,6 @@ func (al *AgentLoop) executeCmdMode(
|
||||||
return al.handleCdCommand(content, sessionKey, agent), nil
|
return al.handleCdCommand(content, sessionKey, agent), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle :edit command
|
|
||||||
if content == ":edit" || strings.HasPrefix(content, ":edit ") {
|
|
||||||
workDir := al.getSessionWorkDir(sessionKey)
|
|
||||||
if workDir == "" {
|
|
||||||
workDir = agent.Workspace
|
|
||||||
}
|
|
||||||
return al.handleEditCommand(content, workDir, agent.Workspace), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intercept interactive editors
|
// Intercept interactive editors
|
||||||
if msg := interceptEditor(content); msg != "" {
|
if msg := interceptEditor(content); msg != "" {
|
||||||
return msg, nil
|
return msg, nil
|
||||||
|
|
@ -2023,7 +1990,7 @@ func shortenHomePath(path string) string {
|
||||||
// :edit <file> -<N> → delete line N
|
// :edit <file> -<N> → delete line N
|
||||||
// :edit <file> -m """<content>""" → write full content (create if needed)
|
// :edit <file> -m """<content>""" → write full content (create if needed)
|
||||||
func (al *AgentLoop) handleEditCommand(content, workDir, workspace string) string {
|
func (al *AgentLoop) handleEditCommand(content, workDir, workspace string) string {
|
||||||
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), ":edit"))
|
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), "/edit"))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return editUsage()
|
return editUsage()
|
||||||
}
|
}
|
||||||
|
|
@ -2044,12 +2011,12 @@ func (al *AgentLoop) handleEditCommand(content, workDir, workspace string) strin
|
||||||
return fmt.Sprintf("Access denied: %s", err)
|
return fmt.Sprintf("Access denied: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// :edit <file> — show file content
|
// /edit <file> — show file content
|
||||||
if len(parts) == 1 && !strings.Contains(raw, "\n") {
|
if len(parts) == 1 && !strings.Contains(raw, "\n") {
|
||||||
return editShowFile(filename)
|
return editShowFile(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// :edit <file> -m """..."""
|
// /edit <file> -m """..."""
|
||||||
if len(parts) >= 2 && parts[1] == "-m" {
|
if len(parts) >= 2 && parts[1] == "-m" {
|
||||||
return editMultiline(filename, raw)
|
return editMultiline(filename, raw)
|
||||||
}
|
}
|
||||||
|
|
@ -2081,11 +2048,11 @@ func resolveEditPath(name, workDir, workspace string) (string, error) {
|
||||||
|
|
||||||
func editUsage() string {
|
func editUsage() string {
|
||||||
return "Usage:\n" +
|
return "Usage:\n" +
|
||||||
" :edit <file> — view file\n" +
|
" /edit <file> — view file\n" +
|
||||||
" :edit <file> <N> <text> — replace line N\n" +
|
" /edit <file> <N> <text> — replace line N\n" +
|
||||||
" :edit <file> +<N> <text> — insert after line N\n" +
|
" /edit <file> +<N> <text> — insert after line N\n" +
|
||||||
" :edit <file> -<N> — delete line N\n" +
|
" /edit <file> -<N> — delete line N\n" +
|
||||||
" :edit <file> -m \"\"\" — write content\n" +
|
" /edit <file> -m \"\"\" — write content\n" +
|
||||||
" <content>\n" +
|
" <content>\n" +
|
||||||
" \"\"\""
|
" \"\"\""
|
||||||
}
|
}
|
||||||
|
|
@ -2095,7 +2062,7 @@ func editShowFile(path string) string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"File not found: %s\nUse :edit %s -m \"\"\" to create it.",
|
"File not found: %s\nUse /edit %s -m \"\"\" to create it.",
|
||||||
shortenHomePath(path),
|
shortenHomePath(path),
|
||||||
filepath.Base(path),
|
filepath.Base(path),
|
||||||
)
|
)
|
||||||
|
|
@ -2262,9 +2229,9 @@ func interceptEditor(cmd string) string {
|
||||||
name := parts[0]
|
name := parts[0]
|
||||||
switch name {
|
switch name {
|
||||||
case "vim", "vi", "nvim", "nano", "emacs", "pico", "joe", "mcedit":
|
case "vim", "vi", "nvim", "nano", "emacs", "pico", "joe", "mcedit":
|
||||||
return fmt.Sprintf("⚠ %s requires a terminal and cannot run here.\nUse :edit instead:\n\n"+
|
return fmt.Sprintf("⚠ %s requires a terminal and cannot run here.\nUse /edit instead:\n\n"+
|
||||||
":edit <file> — view file\n"+
|
"/edit <file> — view file\n"+
|
||||||
":edit <file> -m \"\"\" — write content\n"+
|
"/edit <file> -m \"\"\" — write content\n"+
|
||||||
"<content>\n"+
|
"<content>\n"+
|
||||||
"\"\"\"\n\n"+
|
"\"\"\"\n\n"+
|
||||||
"Type :help for all commands.", name)
|
"Type :help for all commands.", name)
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,14 @@ func BuiltinDefinitions() []Definition {
|
||||||
listCommand(),
|
listCommand(),
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
|
// Session mode commands (formerly : prefix)
|
||||||
|
cmdModeCommand(),
|
||||||
|
picoModeCommand(),
|
||||||
|
hipicoCmnd(),
|
||||||
|
editCommand(),
|
||||||
|
// Info and session management
|
||||||
|
usageCommand(),
|
||||||
|
clearCommand(),
|
||||||
|
compactCommand(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
pkg/commands/cmd_clear.go
Normal file
20
pkg/commands/cmd_clear.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func clearCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "clear",
|
||||||
|
Description: "Clear all chat history for this session",
|
||||||
|
Usage: "/clear",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ClearSession == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.ClearSession(); err != nil {
|
||||||
|
return req.Reply("Failed to clear session: " + err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply("Chat history cleared. Starting fresh.")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
20
pkg/commands/cmd_compact.go
Normal file
20
pkg/commands/cmd_compact.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func compactCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "compact",
|
||||||
|
Description: "Compress chat history to reduce context size",
|
||||||
|
Usage: "/compact",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.CompactSession == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.CompactSession(); err != nil {
|
||||||
|
return req.Reply("Failed to compact session: " + err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply("Chat history compacted.")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
18
pkg/commands/cmd_edit.go
Normal file
18
pkg/commands/cmd_edit.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func editCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "edit",
|
||||||
|
Description: "View or edit files (works in both chat and command mode)",
|
||||||
|
Usage: "/edit <file> [operation]",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.EditFile == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
// Pass the full text; EditFile strips the /edit prefix internally
|
||||||
|
return req.Reply(rt.EditFile(req.Text))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
63
pkg/commands/cmd_mode.go
Normal file
63
pkg/commands/cmd_mode.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdModeCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "cmd",
|
||||||
|
Description: "Switch to command mode (execute shell commands)",
|
||||||
|
Usage: "/cmd",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.SetModeCmd == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
return req.Reply(rt.SetModeCmd())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func picoModeCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "pico",
|
||||||
|
Description: "Switch to chat mode (AI conversation)",
|
||||||
|
Usage: "/pico",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.SetModePico == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
return req.Reply(rt.SetModePico())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hipicoCmnd() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "hipico",
|
||||||
|
Description: "Ask AI for one-shot help (works from command mode)",
|
||||||
|
Usage: "/hipico <message>",
|
||||||
|
Handler: func(ctx context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.RunOneShot == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
// Strip the command prefix to get the message body
|
||||||
|
msg := strings.TrimSpace(req.Text)
|
||||||
|
for _, prefix := range []string{"/hipico", "!hipico"} {
|
||||||
|
if strings.HasPrefix(msg, prefix) {
|
||||||
|
msg = strings.TrimSpace(msg[len(prefix):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
return req.Reply("Usage: /hipico <message>\nExample: /hipico check the log files for errors")
|
||||||
|
}
|
||||||
|
reply, err := rt.RunOneShot(ctx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return req.Reply("Error: " + err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply(reply)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
47
pkg/commands/cmd_usage.go
Normal file
47
pkg/commands/cmd_usage.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func usageCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "usage",
|
||||||
|
Description: "Show model info and token usage for this session",
|
||||||
|
Usage: "/usage",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetModelInfo == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, _ := rt.GetModelInfo()
|
||||||
|
|
||||||
|
var maxTokens int
|
||||||
|
var temperature float64
|
||||||
|
if rt.Config != nil {
|
||||||
|
maxTokens = rt.Config.Agents.Defaults.MaxTokens
|
||||||
|
if rt.Config.Agents.Defaults.Temperature != nil {
|
||||||
|
temperature = *rt.Config.Agents.Defaults.Temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Model: %s\nMax tokens: %d\nTemperature: %.1f",
|
||||||
|
name, maxTokens, temperature)
|
||||||
|
|
||||||
|
if rt.GetTokenUsage != nil {
|
||||||
|
prompt, completion, requests := rt.GetTokenUsage()
|
||||||
|
msg += fmt.Sprintf(`
|
||||||
|
|
||||||
|
Token usage (this session):
|
||||||
|
Prompt tokens: %d
|
||||||
|
Completion tokens: %d
|
||||||
|
Total tokens: %d
|
||||||
|
Requests: %d`,
|
||||||
|
prompt, completion, prompt+completion, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req.Reply(msg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package commands
|
package commands
|
||||||
|
|
||||||
import "github.com/sipeed/picoclaw/pkg/config"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
// Runtime provides runtime dependencies to command handlers. It is constructed
|
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||||
// per-request by the agent loop so that per-request state (like session scope)
|
// per-request by the agent loop so that per-request state (like session scope)
|
||||||
|
|
@ -13,4 +17,26 @@ type Runtime struct {
|
||||||
GetEnabledChannels func() []string
|
GetEnabledChannels func() []string
|
||||||
SwitchModel func(value string) (oldModel string, err error)
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
SwitchChannel func(value string) error
|
SwitchChannel func(value string) error
|
||||||
|
|
||||||
|
// Session mode control (session-scoped callbacks)
|
||||||
|
GetSessionMode func() string // returns "pico" or "cmd"
|
||||||
|
SetModeCmd func() string // switches to cmd mode, returns prompt string
|
||||||
|
SetModePico func() string // switches to pico mode, returns status string
|
||||||
|
|
||||||
|
// Working directory (session-scoped)
|
||||||
|
GetWorkDir func() string // current working directory for this session
|
||||||
|
GetWorkspace func() string // agent workspace root
|
||||||
|
|
||||||
|
// File editing — delegates to the loop's handleEditCommand with proper path resolution
|
||||||
|
EditFile func(content string) string
|
||||||
|
|
||||||
|
// Token and model usage stats
|
||||||
|
GetTokenUsage func() (promptTokens, completionTokens, requests int64)
|
||||||
|
|
||||||
|
// One-shot AI query from cmd mode (/hipico)
|
||||||
|
RunOneShot func(ctx context.Context, message string) (string, error)
|
||||||
|
|
||||||
|
// Session history management
|
||||||
|
ClearSession func() error // clears all history and summary, saves
|
||||||
|
CompactSession func() error // runs summarization synchronously
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue