package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"codey/internal/llm"
"codey/internal/statedir"
)
// Session is a conversation saved to disk so --resume can pick it up.
type Session struct {
ID string `json:"id"`
Root string `json:"root"`
Model string `json:"model"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Turns int `json:"turns"`
Usage llm.Usage `json:"usage"`
Messages []llm.Message `json:"messages"`
}
// SessionDir is where sessions live, relative to the working root.
const SessionDir = statedir.Dir + "/sessions"
// Save writes the session for id, overwriting any previous copy.
func (a *Agent) Save(dir, id, root, model string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
usage, turns := a.state.Usage()
sess := Session{
ID: id,
Root: root,
Model: model,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
Turns: turns,
Usage: usage,
Messages: a.state.History(),
}
if prev, err := LoadSession(filepath.Join(dir, id+".json")); err == nil {
sess.Created = prev.Created
}
b, err := json.MarshalIndent(sess, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, id+".json"), b, 0o644)
}
// LoadSession reads one session file.
func LoadSession(path string) (Session, error) {
var s Session
b, err := os.ReadFile(path)
if err != nil {
return s, err
}
if err := json.Unmarshal(b, &s); err != nil {
return s, fmt.Errorf("session %s: %w", path, err)
}
return s, nil
}
// ResolveSession finds a session by id, or the most recently updated one when
// id is "latest" or empty.
func ResolveSession(dir, id string) (Session, error) {
if id != "" && id != "latest" {
return LoadSession(filepath.Join(dir, id+".json"))
}
entries, err := os.ReadDir(dir)
if err != nil {
return Session{}, fmt.Errorf("no sessions in %s", dir)
}
var files []string
for _, e := range entries {
if !e.IsDir() && filepath.Ext(e.Name()) == ".json" {
files = append(files, filepath.Join(dir, e.Name()))
}
}
if len(files) == 0 {
return Session{}, fmt.Errorf("no sessions in %s", dir)
}
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
return a.ModTime().After(b.ModTime())
})
return LoadSession(files[0])
}
// Restore seeds the agent's state from a saved session.
func (a *Agent) Restore(s Session) {
a.state.Replace(s.Messages)
a.state.RestoreUsage(s.Usage, s.Turns)
// The last request's size is unknown after a restore; the next turn's
// response re-establishes it before compaction is consulted again.
a.lastInputTokens = 0
}