Add session listing capability via Agent interface Implement session persistence in SessionManager Improve Feishu markdown formatting support Add verbose logging and error handling Clean up session switching logic Support session resume via --resume flag Add agent session metadata scanning generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
10 KiB
Go
376 lines
10 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const maxPlatformMessageLen = 4000
|
|
|
|
// 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
|
|
}
|
|
|
|
func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath string) *Engine {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Engine{
|
|
name: name,
|
|
agent: ag,
|
|
platforms: platforms,
|
|
sessions: NewSessionManager(sessionStorePath),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
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()
|
|
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) {
|
|
content := strings.TrimSpace(msg.Content)
|
|
if content == "" {
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(content, "/") {
|
|
e.handleCommand(p, msg, content)
|
|
return
|
|
}
|
|
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
if !session.TryLock() {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, "⏳ Previous request still processing, please wait...")
|
|
return
|
|
}
|
|
|
|
slog.Info("processing message",
|
|
"platform", msg.Platform,
|
|
"user", msg.UserName,
|
|
"session", session.ID,
|
|
)
|
|
|
|
go func() {
|
|
defer session.Unlock()
|
|
e.processMessage(p, msg, session)
|
|
}()
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 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 "/history":
|
|
e.cmdHistory(p, msg, args)
|
|
case "/help":
|
|
e.cmdHelp(p, msg)
|
|
default:
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("Unknown command: %s\nType /help for available commands.", cmd))
|
|
}
|
|
}
|
|
|
|
func (e *Engine) cmdNew(p Platform, msg *Message, args []string) {
|
|
name := "session"
|
|
if len(args) > 0 {
|
|
name = strings.Join(args, " ")
|
|
}
|
|
s := e.sessions.NewSession(msg.SessionKey, name)
|
|
_ = p.Reply(e.ctx, 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 {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ Failed to list sessions: %v", err))
|
|
return
|
|
}
|
|
if len(agentSessions) == 0 {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, "No Claude Code sessions found for this project.")
|
|
return
|
|
}
|
|
|
|
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("**Claude Code Sessions** (%d)\n\n", len(agentSessions)))
|
|
for i := 0; i < limit; i++ {
|
|
s := agentSessions[i]
|
|
marker := "◻"
|
|
if s.ID == activeAgentID {
|
|
marker = "▶"
|
|
}
|
|
shortID := s.ID
|
|
if len(shortID) > 8 {
|
|
shortID = shortID[:8]
|
|
}
|
|
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("\n... and %d more\n", len(agentSessions)-limit))
|
|
}
|
|
sb.WriteString("\n`/switch <id>` to switch session")
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, sb.String())
|
|
}
|
|
|
|
func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) {
|
|
if len(args) == 0 {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, "Usage: /switch <session_id_prefix>")
|
|
return
|
|
}
|
|
prefix := strings.TrimSpace(args[0])
|
|
|
|
agentSessions, err := e.agent.ListSessions(e.ctx)
|
|
if err != nil {
|
|
_ = p.Reply(e.ctx, 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 {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ No session matching prefix %q", prefix))
|
|
return
|
|
}
|
|
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.AgentSessionID = matched.ID
|
|
session.Name = matched.Summary
|
|
e.sessions.Save()
|
|
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx,
|
|
fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", matched.Summary, matched.ID[:8], 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)"
|
|
}
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf(
|
|
"📌 Current session\nName: %s\nClaude Session: %s\nLocal messages: %d",
|
|
s.Name, agentID, len(s.History)))
|
|
}
|
|
|
|
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)
|
|
if len(entries) == 0 {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, "No history in current session.")
|
|
return
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("📜 History (last %d):\n\n", len(entries)))
|
|
for _, e := range entries {
|
|
icon := "👤"
|
|
if e.Role == "assistant" {
|
|
icon = "🤖"
|
|
}
|
|
content := e.Content
|
|
if len(content) > 200 {
|
|
content = content[:200] + "..."
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%s [%s]\n%s\n\n", icon, e.Timestamp.Format("15:04:05"), content))
|
|
}
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, sb.String())
|
|
}
|
|
|
|
func (e *Engine) cmdHelp(p Platform, msg *Message) {
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, `/new [name] — Start a brand-new Claude session
|
|
/list — List Claude Code sessions for this project
|
|
/switch <id_prefix> — Resume an existing Claude session
|
|
/current — Show current active session
|
|
/history [n] — Show last n messages (default 10)
|
|
/help — Show this help`)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Agent message processing
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
func (e *Engine) processMessage(p Platform, msg *Message, session *Session) {
|
|
session.AddHistory("user", msg.Content)
|
|
|
|
fullResponse, toolSummary, err := e.runAgent(session, msg.Content)
|
|
|
|
if err != nil && strings.Contains(err.Error(), "already in use") {
|
|
oldID := session.AgentSessionID
|
|
session.AgentSessionID = ""
|
|
slog.Warn("session in use, starting new session", "old_session", oldID)
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, "⚠️ Previous Claude session is locked, starting new session...")
|
|
fullResponse, toolSummary, err = e.runAgent(session, msg.Content)
|
|
}
|
|
|
|
if err != nil {
|
|
slog.Error("agent event error", "error", err)
|
|
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ Error: %v", err))
|
|
return
|
|
}
|
|
|
|
if fullResponse == "" {
|
|
fullResponse = "(empty response)"
|
|
}
|
|
|
|
if len(toolSummary) > 0 {
|
|
fullResponse += "\n\n---\n📎 Tools used:\n" + strings.Join(toolSummary, "\n")
|
|
}
|
|
|
|
session.AddHistory("assistant", fullResponse)
|
|
|
|
slog.Debug("agent response",
|
|
"session", session.ID,
|
|
"agent_session", session.AgentSessionID,
|
|
"tools", len(toolSummary),
|
|
"response_len", len(fullResponse),
|
|
"response_preview", truncate(fullResponse, 200),
|
|
)
|
|
|
|
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
|
|
if err := p.Reply(e.ctx, msg.ReplyCtx, chunk); err != nil {
|
|
slog.Error("failed to send reply", "platform", p.Name(), "error", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
e.sessions.Save()
|
|
}
|
|
|
|
// runAgent executes the agent and collects the full response.
|
|
// Returns (response, toolSummary, error). On the first fatal event error it
|
|
// drains remaining events and returns the error.
|
|
func (e *Engine) runAgent(session *Session, prompt string) (string, []string, error) {
|
|
responseCh, err := e.agent.Execute(e.ctx, session.AgentSessionID, prompt)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
var fullResponse string
|
|
var toolSummary []string
|
|
|
|
for ev := range responseCh {
|
|
if ev.Error != nil {
|
|
for range responseCh { // drain
|
|
}
|
|
return "", nil, ev.Error
|
|
}
|
|
|
|
switch ev.Type {
|
|
case EventToolUse:
|
|
summary := ev.ToolName
|
|
if ev.ToolInput != "" {
|
|
summary += ": " + ev.ToolInput
|
|
}
|
|
toolSummary = append(toolSummary, "- "+summary)
|
|
case EventResult, EventText:
|
|
if ev.Content != "" {
|
|
fullResponse = ev.Content
|
|
}
|
|
}
|
|
|
|
if ev.SessionID != "" && session.AgentSessionID == "" {
|
|
session.AgentSessionID = ev.SessionID
|
|
}
|
|
}
|
|
|
|
return fullResponse, toolSummary, nil
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
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
|
|
}
|