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>
234 lines
5.5 KiB
Go
234 lines
5.5 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Session tracks one conversation between a user and the agent.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
AgentSessionID string `json:"agent_session_id"`
|
|
History []HistoryEntry `json:"history"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
mu sync.Mutex `json:"-"`
|
|
busy bool `json:"-"`
|
|
}
|
|
|
|
func (s *Session) TryLock() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.busy {
|
|
return false
|
|
}
|
|
s.busy = true
|
|
return true
|
|
}
|
|
|
|
func (s *Session) Unlock() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.busy = false
|
|
s.UpdatedAt = time.Now()
|
|
}
|
|
|
|
func (s *Session) AddHistory(role, content string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.History = append(s.History, HistoryEntry{
|
|
Role: role,
|
|
Content: content,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
// GetHistory returns the last n entries. If n <= 0, returns all.
|
|
func (s *Session) GetHistory(n int) []HistoryEntry {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
total := len(s.History)
|
|
if n <= 0 || n > total {
|
|
n = total
|
|
}
|
|
out := make([]HistoryEntry, n)
|
|
copy(out, s.History[total-n:])
|
|
return out
|
|
}
|
|
|
|
// sessionSnapshot is the JSON-serializable state of the SessionManager.
|
|
type sessionSnapshot struct {
|
|
Sessions map[string]*Session `json:"sessions"`
|
|
ActiveSession map[string]string `json:"active_session"`
|
|
UserSessions map[string][]string `json:"user_sessions"`
|
|
Counter int64 `json:"counter"`
|
|
}
|
|
|
|
// SessionManager supports multiple named sessions per user with active-session tracking.
|
|
// It can persist state to a JSON file and reload on startup.
|
|
type SessionManager struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session
|
|
activeSession map[string]string
|
|
userSessions map[string][]string
|
|
counter int64
|
|
storePath string // empty = no persistence
|
|
}
|
|
|
|
func NewSessionManager(storePath string) *SessionManager {
|
|
sm := &SessionManager{
|
|
sessions: make(map[string]*Session),
|
|
activeSession: make(map[string]string),
|
|
userSessions: make(map[string][]string),
|
|
storePath: storePath,
|
|
}
|
|
if storePath != "" {
|
|
sm.load()
|
|
}
|
|
return sm
|
|
}
|
|
|
|
func (sm *SessionManager) nextID() string {
|
|
sm.counter++
|
|
return fmt.Sprintf("s%d", sm.counter)
|
|
}
|
|
|
|
func (sm *SessionManager) GetOrCreateActive(userKey string) *Session {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
if sid, ok := sm.activeSession[userKey]; ok {
|
|
if s, ok := sm.sessions[sid]; ok {
|
|
return s
|
|
}
|
|
}
|
|
return sm.createLocked(userKey, "default")
|
|
}
|
|
|
|
func (sm *SessionManager) NewSession(userKey, name string) *Session {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
s := sm.createLocked(userKey, name)
|
|
sm.saveLocked()
|
|
return s
|
|
}
|
|
|
|
func (sm *SessionManager) createLocked(userKey, name string) *Session {
|
|
id := sm.nextID()
|
|
now := time.Now()
|
|
s := &Session{
|
|
ID: id,
|
|
Name: name,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
sm.sessions[id] = s
|
|
sm.activeSession[userKey] = id
|
|
sm.userSessions[userKey] = append(sm.userSessions[userKey], id)
|
|
return s
|
|
}
|
|
|
|
func (sm *SessionManager) SwitchSession(userKey, target string) (*Session, error) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
for _, sid := range sm.userSessions[userKey] {
|
|
s := sm.sessions[sid]
|
|
if s != nil && (s.ID == target || s.Name == target) {
|
|
sm.activeSession[userKey] = s.ID
|
|
sm.saveLocked()
|
|
return s, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("session %q not found", target)
|
|
}
|
|
|
|
func (sm *SessionManager) ListSessions(userKey string) []*Session {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
|
|
ids := sm.userSessions[userKey]
|
|
out := make([]*Session, 0, len(ids))
|
|
for _, sid := range ids {
|
|
if s, ok := sm.sessions[sid]; ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (sm *SessionManager) ActiveSessionID(userKey string) string {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
return sm.activeSession[userKey]
|
|
}
|
|
|
|
// Save persists current state to disk. Safe to call from outside (e.g. after message processing).
|
|
func (sm *SessionManager) Save() {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
sm.saveLocked()
|
|
}
|
|
|
|
func (sm *SessionManager) saveLocked() {
|
|
if sm.storePath == "" {
|
|
return
|
|
}
|
|
snap := sessionSnapshot{
|
|
Sessions: sm.sessions,
|
|
ActiveSession: sm.activeSession,
|
|
UserSessions: sm.userSessions,
|
|
Counter: sm.counter,
|
|
}
|
|
data, err := json.MarshalIndent(snap, "", " ")
|
|
if err != nil {
|
|
slog.Error("session: failed to marshal", "error", err)
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(sm.storePath), 0o755); err != nil {
|
|
slog.Error("session: failed to create dir", "error", err)
|
|
return
|
|
}
|
|
if err := os.WriteFile(sm.storePath, data, 0o644); err != nil {
|
|
slog.Error("session: failed to write", "path", sm.storePath, "error", err)
|
|
}
|
|
}
|
|
|
|
func (sm *SessionManager) load() {
|
|
data, err := os.ReadFile(sm.storePath)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
slog.Error("session: failed to read", "path", sm.storePath, "error", err)
|
|
}
|
|
return
|
|
}
|
|
var snap sessionSnapshot
|
|
if err := json.Unmarshal(data, &snap); err != nil {
|
|
slog.Error("session: failed to unmarshal", "path", sm.storePath, "error", err)
|
|
return
|
|
}
|
|
sm.sessions = snap.Sessions
|
|
sm.activeSession = snap.ActiveSession
|
|
sm.userSessions = snap.UserSessions
|
|
sm.counter = snap.Counter
|
|
|
|
if sm.sessions == nil {
|
|
sm.sessions = make(map[string]*Session)
|
|
}
|
|
if sm.activeSession == nil {
|
|
sm.activeSession = make(map[string]string)
|
|
}
|
|
if sm.userSessions == nil {
|
|
sm.userSessions = make(map[string][]string)
|
|
}
|
|
|
|
slog.Info("session: loaded from disk", "path", sm.storePath, "sessions", len(sm.sessions))
|
|
}
|