123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127 | // Package history saves conversations as XML files in blinker's state
// directory, one file per session, grouped by project.
package history
import (
"encoding/xml"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"time"
"blinker/internal/config"
"blinker/internal/llm"
)
// Session is one saved conversation.
type Session struct {
XMLName xml.Name `xml:"session"`
Project string `xml:"project,attr"`
Model string `xml:"model,attr"`
Started time.Time `xml:"started,attr"`
Messages []Message `xml:"message"`
}
// Message is one message of a conversation.
type Message struct {
Role string `xml:"role,attr"`
ToolCallID string `xml:"tool_call_id,attr,omitempty"`
Reasoning string `xml:"reasoning,omitempty"`
Content string `xml:"content"`
ToolCalls []ToolCall `xml:"tool_call"`
}
// ToolCall is a tool call an assistant message made.
type ToolCall struct {
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Arguments string `xml:",chardata"`
}
// FromLLM converts a conversation for saving.
func FromLLM(msgs []llm.Message) []Message {
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
hm := Message{Role: m.Role, ToolCallID: m.ToolCallID, Reasoning: m.Reasoning, Content: m.Content}
for _, c := range m.ToolCalls {
hm.ToolCalls = append(hm.ToolCalls, ToolCall{ID: c.ID, Name: c.Function.Name, Arguments: c.Function.Arguments})
}
out = append(out, hm)
}
return out
}
// Dir is where a project's sessions are saved.
func Dir(stateDir, project string) string {
return filepath.Join(stateDir, "history", config.ProjectName(project))
}
// Path is the file a session is saved in.
func (s *Session) Path(stateDir string) string {
return filepath.Join(Dir(stateDir, s.Project), s.Started.UTC().Format("20060102T150405.000Z")+".xml")
}
// Save writes the session, replacing the file from an earlier save.
func (s *Session) Save(stateDir string) error {
data, err := xml.MarshalIndent(s, "", " ")
if err != nil {
return err
}
data = append([]byte(xml.Header), data...)
return config.WriteFile(s.Path(stateDir), append(data, '\n'), 0o600)
}
// Load reads a saved session.
func Load(path string) (*Session, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var s Session
if err := xml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &s, nil
}
// Prompts returns what the user typed in the project's most recent
// sessions, oldest first, up to limit, for recalling earlier input.
func Prompts(stateDir, project string, limit int) ([]string, error) {
dir := Dir(stateDir, project)
entries, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".xml") {
names = append(names, e.Name())
}
}
slices.Sort(names) // timestamps: oldest first
var prompts []string
for i := len(names) - 1; i >= 0 && len(prompts) < limit; i-- {
s, err := Load(filepath.Join(dir, names[i]))
if err != nil {
continue // a damaged file costs only its own prompts
}
var these []string
for _, m := range s.Messages {
if m.Role == "user" {
these = append(these, m.Content)
}
}
prompts = append(these, prompts...)
}
if len(prompts) > limit {
prompts = prompts[len(prompts)-limit:]
}
return prompts, nil
}
|