package core import ( "context" "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strconv" "strings" "sync" "time" "unicode/utf8" ) const maxPlatformMessageLen = 4000 const ( defaultThinkingMaxLen = 300 defaultToolMaxLen = 500 ) // VersionInfo is set by main at startup so that /version works. var VersionInfo string // DisplayCfg controls truncation of intermediate messages. // A value of -1 means "use default", 0 means "no truncation". type DisplayCfg struct { ThinkingMaxLen int // max runes for thinking preview; 0 = no truncation ToolMaxLen int // max runes for tool use preview; 0 = no truncation } // Engine routes messages between platforms and the agent for a single project. type Engine struct { name string agent Agent platforms []Platform sessions *SessionManager ctx context.Context cancel context.CancelFunc i18n *I18n speech SpeechCfg display DisplayCfg startedAt time.Time providerSaveFunc func(providerName string) error providerAddSaveFunc func(p ProviderConfig) error providerRemoveSaveFunc func(name string) error commandSaveAddFunc func(name, description, prompt string) error commandSaveDelFunc func(name string) error cronScheduler *CronScheduler commands *CommandRegistry skills *SkillRegistry // Interactive agent session management interactiveMu sync.Mutex interactiveStates map[string]*interactiveState // key = sessionKey } // interactiveState tracks a running interactive agent session and its permission state. type interactiveState struct { agentSession AgentSession platform Platform replyCtx any mu sync.Mutex pending *pendingPermission approveAll bool // when true, auto-approve all permission requests for this session quiet bool // when true, suppress thinking and tool progress messages } // pendingPermission represents a permission request waiting for user response. type pendingPermission struct { RequestID string ToolName string ToolInput map[string]any InputPreview string Resolved chan struct{} // closed when user responds } func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath string, lang Language) *Engine { ctx, cancel := context.WithCancel(context.Background()) e := &Engine{ name: name, agent: ag, platforms: platforms, sessions: NewSessionManager(sessionStorePath), ctx: ctx, cancel: cancel, i18n: NewI18n(lang), display: DisplayCfg{ThinkingMaxLen: defaultThinkingMaxLen, ToolMaxLen: defaultToolMaxLen}, commands: NewCommandRegistry(), skills: NewSkillRegistry(), interactiveStates: make(map[string]*interactiveState), startedAt: time.Now(), } if cp, ok := ag.(CommandProvider); ok { e.commands.SetAgentDirs(cp.CommandDirs()) } if sp, ok := ag.(SkillProvider); ok { e.skills.SetDirs(sp.SkillDirs()) } return e } // SetSpeechConfig configures the speech-to-text subsystem. func (e *Engine) SetSpeechConfig(cfg SpeechCfg) { e.speech = cfg } // SetDisplayConfig overrides the default truncation settings. func (e *Engine) SetDisplayConfig(cfg DisplayCfg) { e.display = cfg } func (e *Engine) SetLanguageSaveFunc(fn func(Language) error) { e.i18n.SetSaveFunc(fn) } func (e *Engine) SetProviderSaveFunc(fn func(providerName string) error) { e.providerSaveFunc = fn } func (e *Engine) SetProviderAddSaveFunc(fn func(ProviderConfig) error) { e.providerAddSaveFunc = fn } func (e *Engine) SetProviderRemoveSaveFunc(fn func(string) error) { e.providerRemoveSaveFunc = fn } func (e *Engine) SetCronScheduler(cs *CronScheduler) { e.cronScheduler = cs } func (e *Engine) SetCommandSaveAddFunc(fn func(name, description, prompt string) error) { e.commandSaveAddFunc = fn } func (e *Engine) SetCommandSaveDelFunc(fn func(name string) error) { e.commandSaveDelFunc = fn } // AddCommand registers a custom slash command. func (e *Engine) AddCommand(name, description, prompt, source string) { e.commands.Add(name, description, prompt, source) } // RemoveCommand removes a custom command by name. Returns false if not found. func (e *Engine) RemoveCommand(name string) bool { return e.commands.Remove(name) } func (e *Engine) ProjectName() string { return e.name } // ExecuteCronJob runs a cron job by injecting a synthetic message into the engine. // It finds the platform that owns the session key, reconstructs a reply context, // and processes the message as if the user sent it. func (e *Engine) ExecuteCronJob(job *CronJob) error { sessionKey := job.SessionKey platformName := "" if idx := strings.Index(sessionKey, ":"); idx > 0 { platformName = sessionKey[:idx] } var targetPlatform Platform for _, p := range e.platforms { if p.Name() == platformName { targetPlatform = p break } } if targetPlatform == nil { return fmt.Errorf("platform %q not found for session %q", platformName, sessionKey) } rc, ok := targetPlatform.(ReplyContextReconstructor) if !ok { return fmt.Errorf("platform %q does not support proactive messaging (cron)", platformName) } replyCtx, err := rc.ReconstructReplyCtx(sessionKey) if err != nil { return fmt.Errorf("reconstruct reply context: %w", err) } // Notify user that a cron job is executing desc := job.Description if desc == "" { desc = truncateStr(job.Prompt, 40) } e.send(targetPlatform, replyCtx, fmt.Sprintf("⏰ %s", desc)) msg := &Message{ SessionKey: sessionKey, Platform: platformName, UserID: "cron", UserName: "cron", Content: job.Prompt, ReplyCtx: replyCtx, } session := e.sessions.GetOrCreateActive(sessionKey) if !session.TryLock() { return fmt.Errorf("session %q is busy", sessionKey) } e.processInteractiveMessage(targetPlatform, msg, session) return nil } func (e *Engine) Start() error { for _, p := range e.platforms { if err := p.Start(e.handleMessage); err != nil { return fmt.Errorf("[%s] start platform %s: %w", e.name, p.Name(), err) } slog.Info("platform started", "project", e.name, "platform", p.Name()) } slog.Info("engine started", "project", e.name, "agent", e.agent.Name(), "platforms", len(e.platforms)) return nil } func (e *Engine) Stop() error { e.cancel() e.interactiveMu.Lock() for key, state := range e.interactiveStates { if state.agentSession != nil { state.agentSession.Close() } delete(e.interactiveStates, key) } e.interactiveMu.Unlock() var errs []error for _, p := range e.platforms { if err := p.Stop(); err != nil { errs = append(errs, fmt.Errorf("stop platform %s: %w", p.Name(), err)) } } if err := e.agent.Stop(); err != nil { errs = append(errs, fmt.Errorf("stop agent %s: %w", e.agent.Name(), err)) } if len(errs) > 0 { return fmt.Errorf("engine stop errors: %v", errs) } return nil } func (e *Engine) handleMessage(p Platform, msg *Message) { // Voice message: transcribe to text first if msg.Audio != nil { e.handleVoiceMessage(p, msg) return } content := strings.TrimSpace(msg.Content) if content == "" && len(msg.Images) == 0 { return } if len(msg.Images) == 0 && strings.HasPrefix(content, "/") { e.handleCommand(p, msg, content) return } // Permission responses bypass the session lock if e.handlePendingPermission(p, msg, content) { return } session := e.sessions.GetOrCreateActive(msg.SessionKey) if !session.TryLock() { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing)) return } slog.Info("processing message", "platform", msg.Platform, "user", msg.UserName, "session", session.ID, ) go e.processInteractiveMessage(p, msg, session) } // ────────────────────────────────────────────────────────────── // Voice message handling // ────────────────────────────────────────────────────────────── func (e *Engine) handleVoiceMessage(p Platform, msg *Message) { if !e.speech.Enabled || e.speech.STT == nil { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgVoiceNotEnabled)) return } audio := msg.Audio if NeedsConversion(audio.Format) && !HasFFmpeg() { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgVoiceNoFFmpeg)) return } slog.Info("transcribing voice message", "platform", msg.Platform, "user", msg.UserName, "format", audio.Format, "size", len(audio.Data), ) e.send(p, msg.ReplyCtx, e.i18n.T(MsgVoiceTranscribing)) text, err := TranscribeAudio(e.ctx, e.speech.STT, audio, e.speech.Language) if err != nil { slog.Error("speech transcription failed", "error", err) e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgVoiceTranscribeFailed), err)) return } text = strings.TrimSpace(text) if text == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgVoiceEmpty)) return } slog.Info("voice transcribed", "text_len", len(text)) e.send(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgVoiceTranscribed), text)) // Replace audio with transcribed text and re-dispatch msg.Audio = nil msg.Content = text e.handleMessage(p, msg) } // ────────────────────────────────────────────────────────────── // Permission handling // ────────────────────────────────────────────────────────────── func (e *Engine) handlePendingPermission(p Platform, msg *Message, content string) bool { e.interactiveMu.Lock() state, ok := e.interactiveStates[msg.SessionKey] e.interactiveMu.Unlock() if !ok || state == nil { return false } state.mu.Lock() pending := state.pending state.mu.Unlock() if pending == nil { return false } lower := strings.ToLower(strings.TrimSpace(content)) if isApproveAllResponse(lower) { state.mu.Lock() state.approveAll = true state.mu.Unlock() if err := state.agentSession.RespondPermission(pending.RequestID, PermissionResult{ Behavior: "allow", UpdatedInput: pending.ToolInput, }); err != nil { slog.Error("failed to send permission response", "error", err) e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err)) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionApproveAll)) } } else if isAllowResponse(lower) { if err := state.agentSession.RespondPermission(pending.RequestID, PermissionResult{ Behavior: "allow", UpdatedInput: pending.ToolInput, }); err != nil { slog.Error("failed to send permission response", "error", err) e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err)) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionAllowed)) } } else if isDenyResponse(lower) { if err := state.agentSession.RespondPermission(pending.RequestID, PermissionResult{ Behavior: "deny", Message: "User denied this tool use.", }); err != nil { slog.Error("failed to send deny response", "error", err) } e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionDenied)) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionHint)) return true } state.mu.Lock() state.pending = nil state.mu.Unlock() close(pending.Resolved) return true } func isApproveAllResponse(s string) bool { for _, w := range []string{ "allow all", "allowall", "approve all", "yes all", "允许所有", "允许全部", "全部允许", "所有允许", "都允许", "全部同意", } { if s == w { return true } } return false } func isAllowResponse(s string) bool { for _, w := range []string{"allow", "yes", "y", "ok", "允许", "同意", "可以", "好", "好的", "是", "确认", "approve"} { if s == w { return true } } return false } func isDenyResponse(s string) bool { for _, w := range []string{"deny", "no", "n", "reject", "拒绝", "不允许", "不行", "不", "否", "取消", "cancel"} { if s == w { return true } } return false } // ────────────────────────────────────────────────────────────── // Interactive agent processing // ────────────────────────────────────────────────────────────── func (e *Engine) processInteractiveMessage(p Platform, msg *Message, session *Session) { defer session.Unlock() e.i18n.DetectAndSet(msg.Content) session.AddHistory("user", msg.Content) state := e.getOrCreateInteractiveState(msg.SessionKey, p, msg.ReplyCtx, session) // Update reply context for this turn state.mu.Lock() state.platform = p state.replyCtx = msg.ReplyCtx state.mu.Unlock() if state.agentSession == nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), "failed to start agent session")) return } if err := state.agentSession.Send(msg.Content, msg.Images); err != nil { slog.Error("failed to send prompt", "error", err) if !state.agentSession.Alive() { e.cleanupInteractiveState(msg.SessionKey) e.send(p, msg.ReplyCtx, e.i18n.T(MsgSessionRestarting)) state = e.getOrCreateInteractiveState(msg.SessionKey, p, msg.ReplyCtx, session) if state.agentSession == nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), "failed to restart agent session")) return } if err := state.agentSession.Send(msg.Content, msg.Images); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err)) return } } else { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err)) return } } e.processInteractiveEvents(state, session, msg.SessionKey) } func (e *Engine) getOrCreateInteractiveState(sessionKey string, p Platform, replyCtx any, session *Session) *interactiveState { e.interactiveMu.Lock() defer e.interactiveMu.Unlock() state, ok := e.interactiveStates[sessionKey] if ok && state.agentSession != nil && state.agentSession.Alive() { return state } // Inject per-session env vars so the agent subprocess can call `cc-connect cron add` etc. if inj, ok := e.agent.(SessionEnvInjector); ok { envVars := []string{ "CC_PROJECT=" + e.name, "CC_SESSION_KEY=" + sessionKey, } if exePath, err := os.Executable(); err == nil { binDir := filepath.Dir(exePath) if curPath := os.Getenv("PATH"); curPath != "" { envVars = append(envVars, "PATH="+binDir+string(filepath.ListSeparator)+curPath) } else { envVars = append(envVars, "PATH="+binDir) } } inj.SetSessionEnv(envVars) } agentSession, err := e.agent.StartSession(e.ctx, session.AgentSessionID) if err != nil { slog.Error("failed to start interactive session", "error", err) state = &interactiveState{platform: p, replyCtx: replyCtx} e.interactiveStates[sessionKey] = state return state } state = &interactiveState{ agentSession: agentSession, platform: p, replyCtx: replyCtx, } e.interactiveStates[sessionKey] = state slog.Info("interactive session started", "session_key", sessionKey, "agent_session", session.AgentSessionID) return state } func (e *Engine) cleanupInteractiveState(sessionKey string) { e.interactiveMu.Lock() defer e.interactiveMu.Unlock() state, ok := e.interactiveStates[sessionKey] if ok && state.agentSession != nil { state.agentSession.Close() } delete(e.interactiveStates, sessionKey) } func (e *Engine) processInteractiveEvents(state *interactiveState, session *Session, sessionKey string) { var textParts []string toolCount := 0 for event := range state.agentSession.Events() { if e.ctx.Err() != nil { return } state.mu.Lock() p := state.platform replyCtx := state.replyCtx state.mu.Unlock() switch event.Type { case EventThinking: if !state.quiet && event.Content != "" { preview := truncateIf(event.Content, e.display.ThinkingMaxLen) e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgThinking), preview)) } case EventToolUse: toolCount++ if !state.quiet { inputPreview := truncateIf(event.ToolInput, e.display.ToolMaxLen) e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgTool), toolCount, event.ToolName, inputPreview)) } case EventText: if event.Content != "" { textParts = append(textParts, event.Content) } if event.SessionID != "" && session.AgentSessionID == "" { session.AgentSessionID = event.SessionID e.sessions.Save() } case EventPermissionRequest: state.mu.Lock() autoApprove := state.approveAll state.mu.Unlock() if autoApprove { slog.Debug("auto-approving (approve-all)", "request_id", event.RequestID, "tool", event.ToolName) _ = state.agentSession.RespondPermission(event.RequestID, PermissionResult{ Behavior: "allow", UpdatedInput: event.ToolInputRaw, }) continue } slog.Info("permission request", "request_id", event.RequestID, "tool", event.ToolName, ) permLimit := e.display.ToolMaxLen if permLimit > 0 { permLimit = permLimit * 8 / 5 // permission prompts get ~1.6x more room } prompt := fmt.Sprintf(e.i18n.T(MsgPermissionPrompt), event.ToolName, truncateIf(event.ToolInput, permLimit)) e.sendPermissionPrompt(p, replyCtx, prompt) pending := &pendingPermission{ RequestID: event.RequestID, ToolName: event.ToolName, ToolInput: event.ToolInputRaw, InputPreview: event.ToolInput, Resolved: make(chan struct{}), } state.mu.Lock() state.pending = pending state.mu.Unlock() <-pending.Resolved slog.Info("permission resolved", "request_id", event.RequestID) case EventResult: if event.SessionID != "" { session.AgentSessionID = event.SessionID } fullResponse := event.Content if fullResponse == "" && len(textParts) > 0 { fullResponse = strings.Join(textParts, "") } if fullResponse == "" { fullResponse = e.i18n.T(MsgEmptyResponse) } session.AddHistory("assistant", fullResponse) e.sessions.Save() slog.Debug("turn complete", "session", session.ID, "agent_session", session.AgentSessionID, "tools", toolCount, "response_len", len(fullResponse), ) for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { if err := p.Send(e.ctx, replyCtx, chunk); err != nil { slog.Error("failed to send reply", "error", err) return } } return case EventError: if event.Error != nil { slog.Error("agent error", "error", event.Error) e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgError), event.Error)) } return } } // Channel closed - process exited unexpectedly slog.Warn("agent process exited", "session_key", sessionKey) e.cleanupInteractiveState(sessionKey) if len(textParts) > 0 { state.mu.Lock() p := state.platform replyCtx := state.replyCtx state.mu.Unlock() fullResponse := strings.Join(textParts, "") session.AddHistory("assistant", fullResponse) for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { e.send(p, replyCtx, chunk) } } } // ────────────────────────────────────────────────────────────── // Command handling // ────────────────────────────────────────────────────────────── func (e *Engine) handleCommand(p Platform, msg *Message, raw string) { parts := strings.Fields(raw) cmd := strings.ToLower(parts[0]) args := parts[1:] switch cmd { case "/new": e.cmdNew(p, msg, args) case "/list", "/sessions": e.cmdList(p, msg) case "/switch": e.cmdSwitch(p, msg, args) case "/current": e.cmdCurrent(p, msg) case "/status": e.cmdStatus(p, msg) case "/history": e.cmdHistory(p, msg, args) case "/allow": e.cmdAllow(p, msg, args) case "/model": e.cmdModel(p, msg, args) case "/mode": e.cmdMode(p, msg, args) case "/lang": e.cmdLang(p, msg, args) case "/quiet": e.cmdQuiet(p, msg) case "/provider": e.cmdProvider(p, msg, args) case "/memory": e.cmdMemory(p, msg, args) case "/cron": e.cmdCron(p, msg, args) case "/compress", "/compact": e.cmdCompress(p, msg) case "/stop": e.cmdStop(p, msg) case "/help": e.cmdHelp(p, msg) case "/version": e.reply(p, msg.ReplyCtx, VersionInfo) case "/commands", "/command", "/cmd": e.cmdCommands(p, msg, args) case "/skills", "/skill": e.cmdSkills(p, msg) default: cmdName := strings.TrimPrefix(cmd, "/") if custom, ok := e.commands.Resolve(cmdName); ok { e.executeCustomCommand(p, msg, custom, args) return } if skill := e.skills.Resolve(cmdName); skill != nil { e.executeSkill(p, msg, skill, args) return } e.reply(p, msg.ReplyCtx, fmt.Sprintf("Unknown command: %s\nType /help for available commands.", cmd)) } } func (e *Engine) cmdNew(p Platform, msg *Message, args []string) { e.cleanupInteractiveState(msg.SessionKey) name := "session" if len(args) > 0 { name = strings.Join(args, " ") } s := e.sessions.NewSession(msg.SessionKey, name) e.reply(p, msg.ReplyCtx, fmt.Sprintf("✅ New session created: %s (id: %s)", s.Name, s.ID)) } func (e *Engine) cmdList(p Platform, msg *Message) { agentSessions, err := e.agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgListError), err)) return } if len(agentSessions) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgListEmpty)) return } agentName := e.agent.Name() activeSession := e.sessions.GetOrCreateActive(msg.SessionKey) activeAgentID := activeSession.AgentSessionID limit := 20 if len(agentSessions) < limit { limit = len(agentSessions) } var sb strings.Builder sb.WriteString(fmt.Sprintf(e.i18n.T(MsgListTitle), agentName, len(agentSessions))) for i := 0; i < limit; i++ { s := agentSessions[i] marker := "◻" if s.ID == activeAgentID { marker = "▶" } shortID := s.ID if len(shortID) > 12 { shortID = shortID[:12] } summary := s.Summary if summary == "" { summary = "(empty)" } sb.WriteString(fmt.Sprintf("%s `%s` · %s · **%d** msgs · %s\n", marker, shortID, summary, s.MessageCount, s.ModifiedAt.Format("01-02 15:04"))) } if len(agentSessions) > limit { sb.WriteString(fmt.Sprintf(e.i18n.T(MsgListMore), len(agentSessions)-limit)) } sb.WriteString(e.i18n.T(MsgListSwitchHint)) e.reply(p, msg.ReplyCtx, sb.String()) } func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, "Usage: /switch ") return } prefix := strings.TrimSpace(args[0]) agentSessions, err := e.agent.ListSessions(e.ctx) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) return } var matched *AgentSessionInfo for i := range agentSessions { if strings.HasPrefix(agentSessions[i].ID, prefix) { matched = &agentSessions[i] break } } if matched == nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching prefix %q", prefix)) return } e.cleanupInteractiveState(msg.SessionKey) session := e.sessions.GetOrCreateActive(msg.SessionKey) session.AgentSessionID = matched.ID session.Name = matched.Summary session.ClearHistory() e.sessions.Save() shortID := matched.ID if len(shortID) > 12 { shortID = shortID[:12] } e.reply(p, msg.ReplyCtx, fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", matched.Summary, shortID, matched.MessageCount)) } func (e *Engine) cmdCurrent(p Platform, msg *Message) { s := e.sessions.GetOrCreateActive(msg.SessionKey) agentID := s.AgentSessionID if agentID == "" { agentID = "(new — not yet started)" } e.reply(p, msg.ReplyCtx, fmt.Sprintf( "📌 Current session\nName: %s\nClaude Session: %s\nLocal messages: %d", s.Name, agentID, len(s.History))) } func (e *Engine) cmdStatus(p Platform, msg *Message) { // Platforms platNames := make([]string, len(e.platforms)) for i, pl := range e.platforms { platNames[i] = pl.Name() } platformStr := strings.Join(platNames, ", ") if len(platNames) == 0 { platformStr = "-" } // Uptime uptimeStr := formatDurationI18n(time.Since(e.startedAt), e.i18n.CurrentLang()) // Language cur := e.i18n.CurrentLang() langStr := fmt.Sprintf("%s (%s)", string(cur), langDisplayName(cur)) // Mode (optional) var modeStr string if ms, ok := e.agent.(ModeSwitcher); ok { mode := ms.GetMode() if mode != "" { modeStr = e.i18n.Tf(MsgStatusMode, mode) } } // Session info s := e.sessions.GetOrCreateActive(msg.SessionKey) sessionStr := e.i18n.Tf(MsgStatusSession, s.Name, len(s.History)) // Cron jobs var cronStr string if e.cronScheduler != nil { jobs := e.cronScheduler.Store().ListBySessionKey(msg.SessionKey) if len(jobs) > 0 { enabledCount := 0 for _, j := range jobs { if j.Enabled { enabledCount++ } } cronStr = e.i18n.Tf(MsgStatusCron, len(jobs), enabledCount) } } e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgStatusTitle, e.name, e.agent.Name(), platformStr, uptimeStr, langStr, modeStr, sessionStr, cronStr, )) } func cronTimeFormat(t, now time.Time) string { if t.Year() != now.Year() { return "2006-01-02 15:04" } return "01-02 15:04" } func formatDurationI18n(d time.Duration, lang Language) string { d = d.Round(time.Second) days := int(d.Hours()) / 24 hours := int(d.Hours()) % 24 minutes := int(d.Minutes()) % 60 switch lang { case LangChinese, LangTraditionalChinese: if days > 0 { return fmt.Sprintf("%d天 %d小时 %d分钟", days, hours, minutes) } if hours > 0 { return fmt.Sprintf("%d小时 %d分钟", hours, minutes) } return fmt.Sprintf("%d分钟", minutes) case LangJapanese: if days > 0 { return fmt.Sprintf("%d日 %d時間 %d分", days, hours, minutes) } if hours > 0 { return fmt.Sprintf("%d時間 %d分", hours, minutes) } return fmt.Sprintf("%d分", minutes) case LangSpanish: if days > 0 { return fmt.Sprintf("%d días %dh %dm", days, hours, minutes) } if hours > 0 { return fmt.Sprintf("%dh %dm", hours, minutes) } return fmt.Sprintf("%dm", minutes) default: if days > 0 { return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) } if hours > 0 { return fmt.Sprintf("%dh %dm", hours, minutes) } return fmt.Sprintf("%dm", minutes) } } func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) { s := e.sessions.GetOrCreateActive(msg.SessionKey) n := 10 if len(args) > 0 { if v, err := strconv.Atoi(args[0]); err == nil && v > 0 { n = v } } entries := s.GetHistory(n) // Fallback: load from agent backend if in-memory history is empty if len(entries) == 0 && s.AgentSessionID != "" { if hp, ok := e.agent.(HistoryProvider); ok { if agentEntries, err := hp.GetSessionHistory(e.ctx, s.AgentSessionID, n); err == nil { entries = agentEntries } } } if len(entries) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHistoryEmpty)) return } var sb strings.Builder sb.WriteString(fmt.Sprintf("📜 History (last %d):\n\n", len(entries))) for _, h := range entries { icon := "👤" if h.Role == "assistant" { icon = "🤖" } content := h.Content if len([]rune(content)) > 200 { content = string([]rune(content)[:200]) + "..." } sb.WriteString(fmt.Sprintf("%s [%s]\n%s\n\n", icon, h.Timestamp.Format("15:04:05"), content)) } e.reply(p, msg.ReplyCtx, sb.String()) } func (e *Engine) cmdLang(p Platform, msg *Message, args []string) { if len(args) == 0 { cur := e.i18n.CurrentLang() name := langDisplayName(cur) e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgLangCurrent, name)) return } target := strings.ToLower(strings.TrimSpace(args[0])) var lang Language switch target { case "en", "english": lang = LangEnglish case "zh", "cn", "chinese", "中文": lang = LangChinese case "zh-tw", "zh_tw", "zhtw", "繁體", "繁体": lang = LangTraditionalChinese case "ja", "jp", "japanese", "日本語": lang = LangJapanese case "es", "spanish", "español": lang = LangSpanish case "auto": lang = LangAuto default: e.reply(p, msg.ReplyCtx, e.i18n.T(MsgLangInvalid)) return } e.i18n.SetLang(lang) name := langDisplayName(lang) e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgLangChanged, name)) } func langDisplayName(lang Language) string { switch lang { case LangEnglish: return "English" case LangChinese: return "中文" case LangTraditionalChinese: return "繁體中文" case LangJapanese: return "日本語" case LangSpanish: return "Español" default: return "Auto" } } func (e *Engine) cmdHelp(p Platform, msg *Message) { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHelp)) } func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { switcher, ok := e.agent.(ModelSwitcher) if !ok { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgModelNotSupported)) return } fetchCtx, cancel := context.WithTimeout(e.ctx, 10*time.Second) defer cancel() models := switcher.AvailableModels(fetchCtx) if len(args) == 0 { var sb strings.Builder current := switcher.GetModel() if current == "" { sb.WriteString(e.i18n.T(MsgModelDefault)) } else { sb.WriteString(e.i18n.Tf(MsgModelCurrent, current)) sb.WriteString("\n") } sb.WriteString("\n") sb.WriteString(e.i18n.T(MsgModelListTitle)) for i, m := range models { marker := " " if m.Name == current { marker = "> " } desc := m.Desc if desc != "" { desc = " — " + desc } sb.WriteString(fmt.Sprintf("%s%d. %s%s\n", marker, i+1, m.Name, desc)) } sb.WriteString("\n") sb.WriteString(e.i18n.T(MsgModelUsage)) e.reply(p, msg.ReplyCtx, sb.String()) return } target := args[0] if idx, err := strconv.Atoi(target); err == nil && idx >= 1 && idx <= len(models) { target = models[idx-1].Name } switcher.SetModel(target) e.cleanupInteractiveState(msg.SessionKey) s := e.sessions.GetOrCreateActive(msg.SessionKey) s.AgentSessionID = "" s.ClearHistory() e.sessions.Save() e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgModelChanged, target)) } func (e *Engine) cmdMode(p Platform, msg *Message, args []string) { switcher, ok := e.agent.(ModeSwitcher) if !ok { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgModeNotSupported)) return } if len(args) == 0 { current := switcher.GetMode() modes := switcher.PermissionModes() var sb strings.Builder zhLike := e.i18n.IsZhLike() for _, m := range modes { marker := " " if m.Key == current { marker = "▶ " } if zhLike { sb.WriteString(fmt.Sprintf("%s**%s** — %s\n", marker, m.NameZh, m.DescZh)) } else { sb.WriteString(fmt.Sprintf("%s**%s** — %s\n", marker, m.Name, m.Desc)) } } sb.WriteString(e.i18n.T(MsgModeUsage)) e.reply(p, msg.ReplyCtx, sb.String()) return } target := strings.ToLower(args[0]) switcher.SetMode(target) newMode := switcher.GetMode() e.cleanupInteractiveState(msg.SessionKey) modes := switcher.PermissionModes() displayName := newMode zhLike := e.i18n.IsZhLike() for _, m := range modes { if m.Key == newMode { if zhLike { displayName = m.NameZh } else { displayName = m.Name } break } } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgModeChanged), displayName)) } func (e *Engine) cmdQuiet(p Platform, msg *Message) { e.interactiveMu.Lock() state, ok := e.interactiveStates[msg.SessionKey] e.interactiveMu.Unlock() if !ok || state == nil { // No state yet, create one so the flag persists state = &interactiveState{platform: p, replyCtx: msg.ReplyCtx, quiet: true} e.interactiveMu.Lock() e.interactiveStates[msg.SessionKey] = state e.interactiveMu.Unlock() e.reply(p, msg.ReplyCtx, e.i18n.T(MsgQuietOn)) return } state.mu.Lock() state.quiet = !state.quiet quiet := state.quiet state.mu.Unlock() if quiet { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgQuietOn)) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgQuietOff)) } } func (e *Engine) cmdStop(p Platform, msg *Message) { e.interactiveMu.Lock() state, ok := e.interactiveStates[msg.SessionKey] e.interactiveMu.Unlock() if !ok || state == nil { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNoExecution)) return } // Cancel pending permission if any state.mu.Lock() pending := state.pending if pending != nil { state.pending = nil } state.mu.Unlock() if pending != nil { close(pending.Resolved) } e.cleanupInteractiveState(msg.SessionKey) e.reply(p, msg.ReplyCtx, e.i18n.T(MsgExecutionStopped)) } func (e *Engine) cmdCompress(p Platform, msg *Message) { compressor, ok := e.agent.(ContextCompressor) if !ok || compressor.CompressCommand() == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCompressNotSupported)) return } // Check for an active interactive session e.interactiveMu.Lock() state, hasState := e.interactiveStates[msg.SessionKey] e.interactiveMu.Unlock() if !hasState || state == nil || state.agentSession == nil || !state.agentSession.Alive() { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCompressNoSession)) return } session := e.sessions.GetOrCreateActive(msg.SessionKey) if !session.TryLock() { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing)) return } e.send(p, msg.ReplyCtx, e.i18n.T(MsgCompressing)) msg.Content = compressor.CompressCommand() go e.processInteractiveMessage(p, msg, session) } func (e *Engine) cmdAllow(p Platform, msg *Message, args []string) { if len(args) == 0 { if auth, ok := e.agent.(ToolAuthorizer); ok { tools := auth.GetAllowedTools() if len(tools) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNoToolsAllowed)) } else { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCurrentTools), strings.Join(tools, ", "))) } } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgToolAuthNotSupported)) } return } toolName := strings.TrimSpace(args[0]) if auth, ok := e.agent.(ToolAuthorizer); ok { if err := auth.AddAllowedTools(toolName); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgToolAllowFailed), err)) return } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgToolAllowedNew), toolName)) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgToolAuthNotSupported)) } } func (e *Engine) cmdProvider(p Platform, msg *Message, args []string) { switcher, ok := e.agent.(ProviderSwitcher) if !ok { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNotSupported)) return } if len(args) == 0 { current := switcher.GetActiveProvider() if current == nil { providers := switcher.ListProviders() if len(providers) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNone)) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNone)) } return } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderCurrent), current.Name)) return } sub := strings.ToLower(args[0]) switch sub { case "list": providers := switcher.ListProviders() if len(providers) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderListEmpty)) return } current := switcher.GetActiveProvider() var sb strings.Builder sb.WriteString(e.i18n.T(MsgProviderListTitle)) for _, prov := range providers { marker := " " if current != nil && prov.Name == current.Name { marker = "▶ " } detail := prov.Name if prov.BaseURL != "" { detail += " (" + prov.BaseURL + ")" } if prov.Model != "" { detail += " [" + prov.Model + "]" } sb.WriteString(fmt.Sprintf("%s**%s**\n", marker, detail)) } sb.WriteString("\n" + e.i18n.T(MsgProviderSwitchHint)) e.reply(p, msg.ReplyCtx, sb.String()) case "add": e.cmdProviderAdd(p, msg, switcher, args[1:]) case "remove", "rm", "delete": e.cmdProviderRemove(p, msg, switcher, args[1:]) case "switch": if len(args) < 2 { e.reply(p, msg.ReplyCtx, "Usage: /provider switch ") return } e.switchProvider(p, msg, switcher, args[1]) case "current": current := switcher.GetActiveProvider() if current == nil { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNone)) return } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderCurrent), current.Name)) default: e.switchProvider(p, msg, switcher, args[0]) } } func (e *Engine) cmdProviderAdd(p Platform, msg *Message, switcher ProviderSwitcher, args []string) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage)) return } var prov ProviderConfig // Join args back; detect JSON (starts with '{') vs positional raw := strings.Join(args, " ") raw = strings.TrimSpace(raw) if strings.HasPrefix(raw, "{") { // JSON format: /provider add {"name":"relay","api_key":"sk-xxx",...} var jp struct { Name string `json:"name"` APIKey string `json:"api_key"` BaseURL string `json:"base_url"` Model string `json:"model"` Env map[string]string `json:"env"` } if err := json.Unmarshal([]byte(raw), &jp); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), "invalid JSON: "+err.Error())) return } if jp.Name == "" { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), "\"name\" is required")) return } prov = ProviderConfig{Name: jp.Name, APIKey: jp.APIKey, BaseURL: jp.BaseURL, Model: jp.Model, Env: jp.Env} } else { // Positional: /provider add [base_url] [model] if len(args) < 2 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage)) return } prov.Name = args[0] prov.APIKey = args[1] if len(args) > 2 { prov.BaseURL = args[2] } if len(args) > 3 { prov.Model = args[3] } } // Check for duplicates for _, existing := range switcher.ListProviders() { if existing.Name == prov.Name { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), fmt.Sprintf("provider %q already exists", prov.Name))) return } } // Add to runtime updated := append(switcher.ListProviders(), prov) switcher.SetProviders(updated) // Persist to config if e.providerAddSaveFunc != nil { if err := e.providerAddSaveFunc(prov); err != nil { slog.Error("failed to persist provider", "error", err) } } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAdded), prov.Name, prov.Name)) } func (e *Engine) cmdProviderRemove(p Platform, msg *Message, switcher ProviderSwitcher, args []string) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, "Usage: /provider remove ") return } name := args[0] providers := switcher.ListProviders() found := false var remaining []ProviderConfig for _, prov := range providers { if prov.Name == name { found = true } else { remaining = append(remaining, prov) } } if !found { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderNotFound), name)) return } // If removing the active provider, clear it active := switcher.GetActiveProvider() switcher.SetProviders(remaining) if active != nil && active.Name == name { // No active provider after removal slog.Info("removed active provider, clearing selection", "name", name) } // Persist if e.providerRemoveSaveFunc != nil { if err := e.providerRemoveSaveFunc(name); err != nil { slog.Error("failed to persist provider removal", "error", err) } } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderRemoved), name)) } func (e *Engine) switchProvider(p Platform, msg *Message, switcher ProviderSwitcher, name string) { if !switcher.SetActiveProvider(name) { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderNotFound), name)) return } e.cleanupInteractiveState(msg.SessionKey) if e.providerSaveFunc != nil { if err := e.providerSaveFunc(name); err != nil { slog.Error("failed to save provider", "error", err) } } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderSwitched), name)) } // ────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────── // SendToSession sends a message to an active session from an external caller (API/CLI). // If sessionKey is empty, it picks the first active session. func (e *Engine) SendToSession(sessionKey, message string) error { e.interactiveMu.Lock() defer e.interactiveMu.Unlock() var state *interactiveState if sessionKey != "" { state = e.interactiveStates[sessionKey] } else { // Pick the first active session for _, s := range e.interactiveStates { state = s break } } if state == nil || state.platform == nil { return fmt.Errorf("no active session found (key=%q)", sessionKey) } state.mu.Lock() p := state.platform replyCtx := state.replyCtx state.mu.Unlock() return p.Send(e.ctx, replyCtx, message) } // sendPermissionPrompt sends a permission prompt, using inline buttons when the platform supports them. func (e *Engine) sendPermissionPrompt(p Platform, replyCtx any, prompt string) { if bs, ok := p.(InlineButtonSender); ok { buttons := [][]ButtonOption{ { {Text: e.i18n.T(MsgPermBtnAllow), Data: "perm:allow"}, {Text: e.i18n.T(MsgPermBtnDeny), Data: "perm:deny"}, }, { {Text: e.i18n.T(MsgPermBtnAllowAll), Data: "perm:allow_all"}, }, } if err := bs.SendWithButtons(e.ctx, replyCtx, prompt, buttons); err == nil { return } slog.Warn("sendPermissionPrompt: inline buttons failed, falling back to text") } e.send(p, replyCtx, prompt) } // send wraps p.Send with error logging. func (e *Engine) send(p Platform, replyCtx any, content string) { if err := p.Send(e.ctx, replyCtx, content); err != nil { slog.Error("platform send failed", "platform", p.Name(), "error", err, "content_len", len(content)) } } // reply wraps p.Reply with error logging. func (e *Engine) reply(p Platform, replyCtx any, content string) { if err := p.Reply(e.ctx, replyCtx, content); err != nil { slog.Error("platform reply failed", "platform", p.Name(), "error", err, "content_len", len(content)) } } // ────────────────────────────────────────────────────────────── // /memory command // ────────────────────────────────────────────────────────────── func (e *Engine) cmdMemory(p Platform, msg *Message, args []string) { mp, ok := e.agent.(MemoryFileProvider) if !ok { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryNotSupported)) return } if len(args) == 0 { // /memory — show project memory e.showMemoryFile(p, msg, mp.ProjectMemoryFile(), false) return } sub := strings.ToLower(args[0]) switch sub { case "add": text := strings.TrimSpace(strings.Join(args[1:], " ")) if text == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage)) return } e.appendMemoryFile(p, msg, mp.ProjectMemoryFile(), text) case "global": if len(args) == 1 { // /memory global — show global memory e.showMemoryFile(p, msg, mp.GlobalMemoryFile(), true) return } if strings.ToLower(args[1]) == "add" { text := strings.TrimSpace(strings.Join(args[2:], " ")) if text == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage)) return } e.appendMemoryFile(p, msg, mp.GlobalMemoryFile(), text) } else { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage)) } case "show": e.showMemoryFile(p, msg, mp.ProjectMemoryFile(), false) case "help", "--help", "-h": e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage)) default: e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage)) } } func (e *Engine) showMemoryFile(p Platform, msg *Message, filePath string, isGlobal bool) { if filePath == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryNotSupported)) return } data, err := os.ReadFile(filePath) if err != nil || len(strings.TrimSpace(string(data))) == 0 { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryEmpty), filePath)) return } content := string(data) if len([]rune(content)) > 2000 { content = string([]rune(content)[:2000]) + "\n\n... (truncated)" } if isGlobal { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryShowGlobal), filePath, content)) } else { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryShowProject), filePath, content)) } } func (e *Engine) appendMemoryFile(p Platform, msg *Message, filePath, text string) { if filePath == "" { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryNotSupported)) return } dir := filepath.Dir(filePath) if err := os.MkdirAll(dir, 0o755); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAddFailed), err)) return } f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAddFailed), err)) return } defer f.Close() entry := "\n- " + text + "\n" if _, err := f.WriteString(entry); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAddFailed), err)) return } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAdded), filePath)) } // ────────────────────────────────────────────────────────────── // /cron command // ────────────────────────────────────────────────────────────── func (e *Engine) cmdCron(p Platform, msg *Message, args []string) { if e.cronScheduler == nil { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronNotAvailable)) return } if len(args) == 0 { e.cmdCronList(p, msg) return } sub := strings.ToLower(args[0]) switch sub { case "add": e.cmdCronAdd(p, msg, args[1:]) case "list": e.cmdCronList(p, msg) case "del", "delete", "rm", "remove": e.cmdCronDel(p, msg, args[1:]) case "enable": e.cmdCronToggle(p, msg, args[1:], true) case "disable": e.cmdCronToggle(p, msg, args[1:], false) default: e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronUsage)) } } func (e *Engine) cmdCronAdd(p Platform, msg *Message, args []string) { // /cron add if len(args) < 6 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronAddUsage)) return } cronExpr := strings.Join(args[:5], " ") prompt := strings.Join(args[5:], " ") job := &CronJob{ ID: GenerateCronID(), Project: e.name, SessionKey: msg.SessionKey, CronExpr: cronExpr, Prompt: prompt, Enabled: true, CreatedAt: time.Now(), } if err := e.cronScheduler.AddJob(job); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) return } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronAdded), job.ID, cronExpr, truncateStr(prompt, 60))) } func (e *Engine) cmdCronList(p Platform, msg *Message) { jobs := e.cronScheduler.Store().ListBySessionKey(msg.SessionKey) if len(jobs) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronEmpty)) return } lang := e.i18n.CurrentLang() now := time.Now() var sb strings.Builder sb.WriteString(fmt.Sprintf(e.i18n.T(MsgCronListTitle), len(jobs))) sb.WriteString("\n") sb.WriteString("\n") for i, j := range jobs { if i > 0 { sb.WriteString("\n") } status := "✅" if !j.Enabled { status = "⏸" } desc := j.Description if desc == "" { desc = truncateStr(j.Prompt, 60) } sb.WriteString(fmt.Sprintf("%s %s\n", status, desc)) sb.WriteString(fmt.Sprintf("ID: %s\n", j.ID)) human := CronExprToHuman(j.CronExpr, lang) sb.WriteString(e.i18n.Tf(MsgCronScheduleLabel, human, j.CronExpr)) nextRun := e.cronScheduler.NextRun(j.ID) if !nextRun.IsZero() { fmtStr := cronTimeFormat(nextRun, now) sb.WriteString(e.i18n.Tf(MsgCronNextRunLabel, nextRun.Format(fmtStr))) } if !j.LastRun.IsZero() { fmtStr := cronTimeFormat(j.LastRun, now) sb.WriteString(e.i18n.Tf(MsgCronLastRunLabel, j.LastRun.Format(fmtStr))) if j.LastError != "" { sb.WriteString(fmt.Sprintf(" (failed: %s)", truncateStr(j.LastError, 40))) } sb.WriteString("\n") } } sb.WriteString(fmt.Sprintf("\n%s", e.i18n.T(MsgCronListFooter))) e.reply(p, msg.ReplyCtx, sb.String()) } func (e *Engine) cmdCronDel(p Platform, msg *Message, args []string) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronDelUsage)) return } id := args[0] if e.cronScheduler.RemoveJob(id) { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronDeleted), id)) } else { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronNotFound), id)) } } func (e *Engine) cmdCronToggle(p Platform, msg *Message, args []string, enable bool) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronDelUsage)) return } id := args[0] var err error if enable { err = e.cronScheduler.EnableJob(id) } else { err = e.cronScheduler.DisableJob(id) } if err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) return } if enable { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronEnabled), id)) } else { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronDisabled), id)) } } // ────────────────────────────────────────────────────────────── // Custom command execution & management // ────────────────────────────────────────────────────────────── func (e *Engine) executeCustomCommand(p Platform, msg *Message, cmd *CustomCommand, args []string) { prompt := ExpandPrompt(cmd.Prompt, args) session := e.sessions.GetOrCreateActive(msg.SessionKey) if !session.TryLock() { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing)) return } slog.Info("executing custom command", "command", cmd.Name, "source", cmd.Source, "user", msg.UserName, ) msg.Content = prompt go e.processInteractiveMessage(p, msg, session) } func (e *Engine) cmdCommands(p Platform, msg *Message, args []string) { if len(args) == 0 { e.cmdCommandsList(p, msg) return } sub := strings.ToLower(args[0]) switch sub { case "list": e.cmdCommandsList(p, msg) case "add": e.cmdCommandsAdd(p, msg, args[1:]) case "del", "delete", "rm", "remove": e.cmdCommandsDel(p, msg, args[1:]) default: e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsUsage)) } } func (e *Engine) cmdCommandsList(p Platform, msg *Message) { cmds := e.commands.ListAll() if len(cmds) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsEmpty)) return } var sb strings.Builder sb.WriteString(e.i18n.Tf(MsgCommandsTitle, len(cmds))) for _, c := range cmds { desc := c.Description if desc == "" { desc = truncateStr(c.Prompt, 50) } tag := "" if c.Source == "agent" { tag = " [agent]" } sb.WriteString(fmt.Sprintf(" /%s — %s%s\n", c.Name, desc, tag)) } sb.WriteString("\n" + e.i18n.T(MsgCommandsHint)) e.reply(p, msg.ReplyCtx, sb.String()) } func (e *Engine) cmdCommandsAdd(p Platform, msg *Message, args []string) { // /commands add if len(args) < 2 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsAddUsage)) return } name := strings.ToLower(args[0]) prompt := strings.Join(args[1:], " ") if _, exists := e.commands.Resolve(name); exists { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsAddExists), name, name)) return } e.commands.Add(name, "", prompt, "config") if e.commandSaveAddFunc != nil { if err := e.commandSaveAddFunc(name, "", prompt); err != nil { slog.Error("failed to persist command", "error", err) } } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsAdded), name, truncateStr(prompt, 80))) } func (e *Engine) cmdCommandsDel(p Platform, msg *Message, args []string) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsDelUsage)) return } name := strings.ToLower(args[0]) if !e.commands.Remove(name) { e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsNotFound), name)) return } if e.commandSaveDelFunc != nil { if err := e.commandSaveDelFunc(name); err != nil { slog.Error("failed to persist command removal", "error", err) } } e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsDeleted), name)) } // ────────────────────────────────────────────────────────────── // Skill discovery & execution // ────────────────────────────────────────────────────────────── func (e *Engine) executeSkill(p Platform, msg *Message, skill *Skill, args []string) { prompt := BuildSkillInvocationPrompt(skill, args) session := e.sessions.GetOrCreateActive(msg.SessionKey) if !session.TryLock() { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing)) return } slog.Info("executing skill", "skill", skill.Name, "source", skill.Source, "user", msg.UserName, ) msg.Content = prompt go e.processInteractiveMessage(p, msg, session) } func (e *Engine) cmdSkills(p Platform, msg *Message) { skills := e.skills.ListAll() if len(skills) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSkillsEmpty)) return } var sb strings.Builder sb.WriteString(e.i18n.Tf(MsgSkillsTitle, e.agent.Name(), len(skills))) for _, s := range skills { desc := s.Description name := s.Name sb.WriteString(fmt.Sprintf(" /%s — %s\n", name, desc)) } sb.WriteString("\n" + e.i18n.T(MsgSkillsHint)) e.reply(p, msg.ReplyCtx, sb.String()) } // truncateIf truncates s to maxLen runes. 0 means no truncation. func truncateIf(s string, maxLen int) string { if maxLen <= 0 { return s } if utf8.RuneCountInString(s) <= maxLen { return s } return string([]rune(s)[:maxLen]) + "..." } func splitMessage(text string, maxLen int) []string { if len(text) <= maxLen { return []string{text} } var chunks []string for len(text) > 0 { end := maxLen if end > len(text) { end = len(text) } if end < len(text) { if idx := strings.LastIndex(text[:end], "\n"); idx > 0 { end = idx + 1 } } chunks = append(chunks, text[:end]) text = text[end:] } return chunks }