diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 34def59db..6514ad0db 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -634,11 +634,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) 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. // Global commands (/help, /show, /switch) work even when routing fails; // context-dependent commands check their own Runtime fields and report // "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 } @@ -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", map[string]any{ "agent_id": agent.ID, - "scope_key": scopeKey, + "scope_key": sessionKey, "session_key": sessionKey, "matched_by": route.MatchedBy, "route_agent": route.AgentID, "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 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 + content := strings.TrimSpace(msg.Content) switch al.getSessionMode(sessionKey) { case modeCmd: return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID) @@ -1712,14 +1686,8 @@ func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, agent *AgentInstance, + sessionKey string, ) (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) { return "", false } @@ -1728,7 +1696,7 @@ func (al *AgentLoop) handleCommand( return "", false } - rt := al.buildCommandsRuntime(agent) + rt := al.buildCommandsRuntime(agent, sessionKey, msg) executor := commands.NewExecutor(al.cmdRegistry, rt) 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{ Config: al.cfg, ListAgentIDs: al.registry.ListAgentIDs, @@ -1777,7 +1745,107 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtim } 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 { rt.GetModelInfo = func() (string, string) { 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) } -// 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 - Ask AI for help (from command mode, one-shot) -:edit - 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 \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. // Output is formatted as a console code block for channel display. func (al *AgentLoop) executeCmdMode( @@ -1907,15 +1883,6 @@ func (al *AgentLoop) executeCmdMode( 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 if msg := interceptEditor(content); msg != "" { return msg, nil @@ -2023,7 +1990,7 @@ func shortenHomePath(path string) string { // :edit - → delete line N // :edit -m """""" → write full content (create if needed) 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 == "" { return editUsage() } @@ -2044,12 +2011,12 @@ func (al *AgentLoop) handleEditCommand(content, workDir, workspace string) strin return fmt.Sprintf("Access denied: %s", err) } - // :edit — show file content + // /edit — show file content if len(parts) == 1 && !strings.Contains(raw, "\n") { return editShowFile(filename) } - // :edit -m """...""" + // /edit -m """...""" if len(parts) >= 2 && parts[1] == "-m" { return editMultiline(filename, raw) } @@ -2081,11 +2048,11 @@ func resolveEditPath(name, workDir, workspace string) (string, error) { func editUsage() string { return "Usage:\n" + - " :edit — view file\n" + - " :edit — replace line N\n" + - " :edit + — insert after line N\n" + - " :edit - — delete line N\n" + - " :edit -m \"\"\" — write content\n" + + " /edit — view file\n" + + " /edit — replace line N\n" + + " /edit + — insert after line N\n" + + " /edit - — delete line N\n" + + " /edit -m \"\"\" — write content\n" + " \n" + " \"\"\"" } @@ -2095,7 +2062,7 @@ func editShowFile(path string) string { if err != nil { if os.IsNotExist(err) { 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), filepath.Base(path), ) @@ -2262,9 +2229,9 @@ func interceptEditor(cmd string) string { name := parts[0] switch name { 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"+ - ":edit — view file\n"+ - ":edit -m \"\"\" — write content\n"+ + return fmt.Sprintf("⚠ %s requires a terminal and cannot run here.\nUse /edit instead:\n\n"+ + "/edit — view file\n"+ + "/edit -m \"\"\" — write content\n"+ "\n"+ "\"\"\"\n\n"+ "Type :help for all commands.", name) diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a36dd3eba..8c408aebc 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -12,5 +12,14 @@ func BuiltinDefinitions() []Definition { listCommand(), switchCommand(), checkCommand(), + // Session mode commands (formerly : prefix) + cmdModeCommand(), + picoModeCommand(), + hipicoCmnd(), + editCommand(), + // Info and session management + usageCommand(), + clearCommand(), + compactCommand(), } } diff --git a/pkg/commands/cmd_clear.go b/pkg/commands/cmd_clear.go new file mode 100644 index 000000000..7d30d6ee9 --- /dev/null +++ b/pkg/commands/cmd_clear.go @@ -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.") + }, + } +} diff --git a/pkg/commands/cmd_compact.go b/pkg/commands/cmd_compact.go new file mode 100644 index 000000000..28feb2d6b --- /dev/null +++ b/pkg/commands/cmd_compact.go @@ -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.") + }, + } +} diff --git a/pkg/commands/cmd_edit.go b/pkg/commands/cmd_edit.go new file mode 100644 index 000000000..bd770e65d --- /dev/null +++ b/pkg/commands/cmd_edit.go @@ -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 [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)) + }, + } +} diff --git a/pkg/commands/cmd_mode.go b/pkg/commands/cmd_mode.go new file mode 100644 index 000000000..4ea6ee15f --- /dev/null +++ b/pkg/commands/cmd_mode.go @@ -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 ", + 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 \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) + }, + } +} diff --git a/pkg/commands/cmd_usage.go b/pkg/commands/cmd_usage.go new file mode 100644 index 000000000..bac9d88c2 --- /dev/null +++ b/pkg/commands/cmd_usage.go @@ -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) + }, + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 227d495f4..51df0b5f5 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -1,6 +1,10 @@ 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 // per-request by the agent loop so that per-request state (like session scope) @@ -13,4 +17,26 @@ type Runtime struct { GetEnabledChannels func() []string SwitchModel func(value string) (oldModel string, err 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 }