f235b8ce90
- Move cmd/agentu/main.go → main.go (project root) - Move internal/llm/ → pkg/llm/ (leaf package, no internal deps) - Move internal/config/ → pkg/config/ (leaf package, no internal deps) - Update all import paths: agentu/internal/llm → agentu/pkg/llm (14 files) - Update all import paths: agentu/internal/config → agentu/pkg/config (3 files) - Update README: build/run commands, CLI flags section - agent/session/tools/tui stay in internal/ (app-specific business logic) Build: go build . | Test: go test ./... | Vet: go vet ./...
175 lines
4.2 KiB
Go
175 lines
4.2 KiB
Go
package session
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"agentu/pkg/llm"
|
|
)
|
|
|
|
// Session represents a persisted conversation.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
Messages []llm.Message `json:"messages"`
|
|
LastUsage *llm.Usage `json:"last_usage,omitempty"`
|
|
}
|
|
|
|
// SessionMeta is a lightweight summary for listing.
|
|
type SessionMeta struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
MessageCount int `json:"message_count"`
|
|
}
|
|
|
|
// Store manages session files under a directory.
|
|
type Store struct {
|
|
dir string
|
|
}
|
|
|
|
// NewStore creates a store, ensuring the directory exists.
|
|
func NewStore(dir string) (*Store, error) {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create session dir: %w", err)
|
|
}
|
|
return &Store{dir: dir}, nil
|
|
}
|
|
|
|
// Save writes a session to disk.
|
|
func (s *Store) Save(sess *Session) error {
|
|
if sess.ID == "" {
|
|
return fmt.Errorf("session ID is required")
|
|
}
|
|
sess.UpdatedAt = time.Now().UTC()
|
|
if sess.CreatedAt.IsZero() {
|
|
sess.CreatedAt = sess.UpdatedAt
|
|
}
|
|
data, err := json.MarshalIndent(sess, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal session: %w", err)
|
|
}
|
|
path := s.filePath(sess.ID)
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return fmt.Errorf("write session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Load reads a session by ID.
|
|
func (s *Store) Load(id string) (*Session, error) {
|
|
path := s.filePath(id)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("session %q not found", id)
|
|
}
|
|
return nil, fmt.Errorf("read session: %w", err)
|
|
}
|
|
var sess Session
|
|
if err := json.Unmarshal(data, &sess); err != nil {
|
|
return nil, fmt.Errorf("parse session: %w", err)
|
|
}
|
|
return &sess, nil
|
|
}
|
|
|
|
// List returns metadata for all sessions, sorted by updated_at descending.
|
|
func (s *Store) List() ([]SessionMeta, error) {
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read session dir: %w", err)
|
|
}
|
|
var metas []SessionMeta
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
path := filepath.Join(s.dir, entry.Name())
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var sess Session
|
|
if err := json.Unmarshal(data, &sess); err != nil {
|
|
continue
|
|
}
|
|
metas = append(metas, SessionMeta{
|
|
ID: sess.ID,
|
|
Name: sess.Name,
|
|
CreatedAt: sess.CreatedAt,
|
|
UpdatedAt: sess.UpdatedAt,
|
|
Provider: sess.Provider,
|
|
Model: sess.Model,
|
|
MessageCount: len(sess.Messages),
|
|
})
|
|
}
|
|
sort.Slice(metas, func(i, j int) bool {
|
|
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
|
})
|
|
return metas, nil
|
|
}
|
|
|
|
// Delete removes a session file.
|
|
func (s *Store) Delete(id string) error {
|
|
path := s.filePath(id)
|
|
if err := os.Remove(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("session %q not found", id)
|
|
}
|
|
return fmt.Errorf("delete session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) filePath(id string) string {
|
|
return filepath.Join(s.dir, id+".json")
|
|
}
|
|
|
|
// GenerateID creates a random 8-character hex session ID.
|
|
func GenerateID() string {
|
|
b := make([]byte, 4)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// DefaultName derives a session name from the first user message.
|
|
func DefaultName(messages []llm.Message) string {
|
|
for _, msg := range messages {
|
|
if msg.Role != llm.RoleUser {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(msg.Content)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
// Take first line, truncate at 40 chars at word boundary
|
|
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
|
text = text[:idx]
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if len(text) > 40 {
|
|
text = text[:40]
|
|
if idx := strings.LastIndexByte(text, ' '); idx > 20 {
|
|
text = text[:idx]
|
|
}
|
|
text += "..."
|
|
}
|
|
return text
|
|
}
|
|
return ""
|
|
}
|