Add context-aware select statements when sending events to prevent blocking on closed channels. Implement secure argument logging using RedactArgs to mask sensitive flags. Add atomic file writes and locks for config safety. Improve session handling with mutex protection and deduplication checks. generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
2197 lines
60 KiB
Go
2197 lines
60 KiB
Go
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
|
|
)
|
|
|
|
// Slow-operation thresholds. Operations exceeding these durations produce a
|
|
// slog.Warn so operators can quickly pinpoint bottlenecks.
|
|
const (
|
|
slowPlatformSend = 2 * time.Second // platform Reply / Send
|
|
slowAgentStart = 5 * time.Second // agent.StartSession
|
|
slowAgentClose = 3 * time.Second // agentSession.Close
|
|
slowAgentSend = 2 * time.Second // agentSession.Send
|
|
slowAgentFirstEvent = 15 * time.Second // time from send to first agent event
|
|
)
|
|
|
|
// 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
|
|
|
|
displaySaveFunc func(thinkingMaxLen, toolMaxLen *int) 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
|
|
resolveOnce sync.Once
|
|
}
|
|
|
|
// resolve safely closes the Resolved channel exactly once.
|
|
func (pp *pendingPermission) resolve() {
|
|
pp.resolveOnce.Do(func() { close(pp.Resolved) })
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (e *Engine) SetDisplaySaveFunc(fn func(thinkingMaxLen, toolMaxLen *int) error) {
|
|
e.displaySaveFunc = 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()
|
|
pending.resolve()
|
|
|
|
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()
|
|
turnStart := time.Now()
|
|
|
|
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
|
|
}
|
|
|
|
sendStart := time.Now()
|
|
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
|
|
}
|
|
sendStart = time.Now()
|
|
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
|
|
}
|
|
}
|
|
if elapsed := time.Since(sendStart); elapsed >= slowAgentSend {
|
|
slog.Warn("slow agent send", "elapsed", elapsed, "session", msg.SessionKey, "content_len", len(msg.Content))
|
|
}
|
|
|
|
e.processInteractiveEvents(state, session, msg.SessionKey, turnStart)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
startAt := time.Now()
|
|
agentSession, err := e.agent.StartSession(e.ctx, session.AgentSessionID)
|
|
startElapsed := time.Since(startAt)
|
|
if err != nil {
|
|
slog.Error("failed to start interactive session", "error", err, "elapsed", startElapsed)
|
|
state = &interactiveState{platform: p, replyCtx: replyCtx}
|
|
e.interactiveStates[sessionKey] = state
|
|
return state
|
|
}
|
|
if startElapsed >= slowAgentStart {
|
|
slog.Warn("slow agent session start", "elapsed", startElapsed, "agent", e.agent.Name(), "session_id", session.AgentSessionID)
|
|
}
|
|
|
|
state = &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: replyCtx,
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
slog.Info("interactive session started", "session_key", sessionKey, "agent_session", session.AgentSessionID, "elapsed", startElapsed)
|
|
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 {
|
|
closeStart := time.Now()
|
|
state.agentSession.Close()
|
|
if elapsed := time.Since(closeStart); elapsed >= slowAgentClose {
|
|
slog.Warn("slow agent session close", "elapsed", elapsed, "session", sessionKey)
|
|
}
|
|
}
|
|
delete(e.interactiveStates, sessionKey)
|
|
}
|
|
|
|
func (e *Engine) processInteractiveEvents(state *interactiveState, session *Session, sessionKey string, turnStart time.Time) {
|
|
var textParts []string
|
|
toolCount := 0
|
|
waitStart := time.Now()
|
|
firstEventLogged := false
|
|
|
|
for event := range state.agentSession.Events() {
|
|
if e.ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
if !firstEventLogged {
|
|
firstEventLogged = true
|
|
if elapsed := time.Since(waitStart); elapsed >= slowAgentFirstEvent {
|
|
slog.Warn("slow agent first event", "elapsed", elapsed, "session", sessionKey, "event_type", event.Type)
|
|
}
|
|
}
|
|
|
|
state.mu.Lock()
|
|
p := state.platform
|
|
replyCtx := state.replyCtx
|
|
quiet := state.quiet
|
|
state.mu.Unlock()
|
|
|
|
switch event.Type {
|
|
case EventThinking:
|
|
if !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 !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.mu.Lock()
|
|
if session.AgentSessionID == "" {
|
|
session.AgentSessionID = event.SessionID
|
|
session.mu.Unlock()
|
|
e.sessions.Save()
|
|
} else {
|
|
session.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
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.mu.Lock()
|
|
session.AgentSessionID = event.SessionID
|
|
session.mu.Unlock()
|
|
}
|
|
|
|
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()
|
|
|
|
turnDuration := time.Since(turnStart)
|
|
slog.Info("turn complete",
|
|
"session", session.ID,
|
|
"agent_session", session.AgentSessionID,
|
|
"tools", toolCount,
|
|
"response_len", len(fullResponse),
|
|
"turn_duration", turnDuration,
|
|
)
|
|
|
|
replyStart := time.Now()
|
|
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
|
|
}
|
|
}
|
|
if elapsed := time.Since(replyStart); elapsed >= slowPlatformSend {
|
|
slog.Warn("slow final reply send", "platform", p.Name(), "elapsed", elapsed, "response_len", len(fullResponse))
|
|
}
|
|
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)
|
|
case "/config":
|
|
e.cmdConfig(p, msg, args)
|
|
case "/doctor":
|
|
e.cmdDoctor(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 <session_id_prefix>")
|
|
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 {
|
|
pending.resolve()
|
|
}
|
|
|
|
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 <name>")
|
|
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 <name> <api_key> [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 <name>")
|
|
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 {
|
|
return fmt.Errorf("no active session found (key=%q)", sessionKey)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
p := state.platform
|
|
replyCtx := state.replyCtx
|
|
state.mu.Unlock()
|
|
|
|
if p == nil {
|
|
return fmt.Errorf("no active session found (key=%q)", sessionKey)
|
|
}
|
|
|
|
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 and slow-operation warnings.
|
|
func (e *Engine) send(p Platform, replyCtx any, content string) {
|
|
start := time.Now()
|
|
if err := p.Send(e.ctx, replyCtx, content); err != nil {
|
|
slog.Error("platform send failed", "platform", p.Name(), "error", err, "content_len", len(content))
|
|
}
|
|
if elapsed := time.Since(start); elapsed >= slowPlatformSend {
|
|
slog.Warn("slow platform send", "platform", p.Name(), "elapsed", elapsed, "content_len", len(content))
|
|
}
|
|
}
|
|
|
|
// reply wraps p.Reply with error logging and slow-operation warnings.
|
|
func (e *Engine) reply(p Platform, replyCtx any, content string) {
|
|
start := time.Now()
|
|
if err := p.Reply(e.ctx, replyCtx, content); err != nil {
|
|
slog.Error("platform reply failed", "platform", p.Name(), "error", err, "content_len", len(content))
|
|
}
|
|
if elapsed := time.Since(start); elapsed >= slowPlatformSend {
|
|
slog.Warn("slow platform reply", "platform", p.Name(), "elapsed", elapsed, "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 <min> <hour> <day> <month> <weekday> <prompt...>
|
|
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 <name> <prompt...>
|
|
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())
|
|
}
|
|
|
|
// ── /config command ──────────────────────────────────────────
|
|
|
|
// configItem describes a configurable runtime parameter.
|
|
type configItem struct {
|
|
key string
|
|
desc string // en description
|
|
descZh string // zh description
|
|
getFunc func() string
|
|
setFunc func(string) error
|
|
}
|
|
|
|
func (ci configItem) description(isZh bool) string {
|
|
if isZh && ci.descZh != "" {
|
|
return ci.descZh
|
|
}
|
|
return ci.desc
|
|
}
|
|
|
|
func (e *Engine) configItems() []configItem {
|
|
return []configItem{
|
|
{
|
|
key: "thinking_max_len",
|
|
desc: "Max chars for thinking messages (0=no truncation)",
|
|
descZh: "思考消息最大长度 (0=不截断)",
|
|
getFunc: func() string {
|
|
return fmt.Sprintf("%d", e.display.ThinkingMaxLen)
|
|
},
|
|
setFunc: func(v string) error {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid integer: %s", v)
|
|
}
|
|
if n < 0 {
|
|
return fmt.Errorf("value must be >= 0")
|
|
}
|
|
e.display.ThinkingMaxLen = n
|
|
if e.displaySaveFunc != nil {
|
|
return e.displaySaveFunc(&n, nil)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
key: "tool_max_len",
|
|
desc: "Max chars for tool use messages (0=no truncation)",
|
|
descZh: "工具消息最大长度 (0=不截断)",
|
|
getFunc: func() string {
|
|
return fmt.Sprintf("%d", e.display.ToolMaxLen)
|
|
},
|
|
setFunc: func(v string) error {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid integer: %s", v)
|
|
}
|
|
if n < 0 {
|
|
return fmt.Errorf("value must be >= 0")
|
|
}
|
|
e.display.ToolMaxLen = n
|
|
if e.displaySaveFunc != nil {
|
|
return e.displaySaveFunc(nil, &n)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (e *Engine) cmdConfig(p Platform, msg *Message, args []string) {
|
|
items := e.configItems()
|
|
isZh := e.i18n.IsZhLike()
|
|
|
|
if len(args) == 0 {
|
|
var sb strings.Builder
|
|
sb.WriteString(e.i18n.T(MsgConfigTitle))
|
|
for _, item := range items {
|
|
sb.WriteString(fmt.Sprintf("`%s` = `%s`\n %s\n\n", item.key, item.getFunc(), item.description(isZh)))
|
|
}
|
|
sb.WriteString(e.i18n.T(MsgConfigHint))
|
|
e.reply(p, msg.ReplyCtx, sb.String())
|
|
return
|
|
}
|
|
|
|
sub := strings.ToLower(args[0])
|
|
|
|
switch sub {
|
|
case "get":
|
|
if len(args) < 2 {
|
|
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgConfigGetUsage))
|
|
return
|
|
}
|
|
key := strings.ToLower(args[1])
|
|
for _, item := range items {
|
|
if item.key == key {
|
|
e.reply(p, msg.ReplyCtx, fmt.Sprintf("`%s` = `%s`\n %s", key, item.getFunc(), item.description(isZh)))
|
|
return
|
|
}
|
|
}
|
|
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigKeyNotFound, key))
|
|
|
|
case "set":
|
|
if len(args) < 3 {
|
|
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgConfigSetUsage))
|
|
return
|
|
}
|
|
key := strings.ToLower(args[1])
|
|
value := args[2]
|
|
for _, item := range items {
|
|
if item.key == key {
|
|
if err := item.setFunc(value); err != nil {
|
|
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
|
|
return
|
|
}
|
|
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigUpdated, key, item.getFunc()))
|
|
return
|
|
}
|
|
}
|
|
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigKeyNotFound, key))
|
|
|
|
default:
|
|
key := strings.ToLower(sub)
|
|
for _, item := range items {
|
|
if item.key == key {
|
|
if len(args) >= 2 {
|
|
if err := item.setFunc(args[1]); err != nil {
|
|
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
|
|
return
|
|
}
|
|
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigUpdated, key, item.getFunc()))
|
|
} else {
|
|
e.reply(p, msg.ReplyCtx, fmt.Sprintf("`%s` = `%s`\n %s", key, item.getFunc(), item.description(isZh)))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigKeyNotFound, key))
|
|
}
|
|
}
|
|
|
|
// ── /doctor command ─────────────────────────────────────────
|
|
|
|
func (e *Engine) cmdDoctor(p Platform, msg *Message) {
|
|
results := RunDoctorChecks(e.ctx, e.agent, e.platforms)
|
|
report := FormatDoctorResults(results, e.i18n)
|
|
e.reply(p, msg.ReplyCtx, report)
|
|
}
|
|
|
|
// 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
|
|
}
|