Download
127 lines 3.4 KiBGoModified 2026-10-31 raw
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
}