230 lines
5.5 KiB
Go
230 lines
5.5 KiB
Go
package session
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"nub/internal/llm"
|
|
)
|
|
|
|
const (
|
|
dirPerm = 0o700
|
|
filePerm = 0o600
|
|
)
|
|
|
|
// record ist die JSONL-Zeile: entweder ein Node oder ein Head-Pointer.
|
|
// Jeder Record trägt "v":1 (4.5) — unbekannte höhere Version ist ein Fehler,
|
|
// kein stilles Teilparsen.
|
|
type record struct {
|
|
V int `json:"v"`
|
|
Node *Node `json:"node,omitempty"`
|
|
Head string `json:"head,omitempty"`
|
|
}
|
|
|
|
// Store bindet eine Session an ihre Append-only-JSONL-Datei unter
|
|
// .nub/sessions/<id>.jsonl.
|
|
type Store struct {
|
|
*Session
|
|
file *os.File
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func sessionsDir(repoRoot string) string {
|
|
return filepath.Join(repoRoot, ".nub", "sessions")
|
|
}
|
|
|
|
// Create legt eine neue Session mit frischer ULID an und öffnet ihre Datei.
|
|
func Create(repoRoot string) (*Store, error) {
|
|
dir := sessionsDir(repoRoot)
|
|
if err := os.MkdirAll(dir, dirPerm); err != nil {
|
|
return nil, fmt.Errorf("create session dir: %w", err)
|
|
}
|
|
if err := EnsureGitExclude(repoRoot); err != nil {
|
|
return nil, fmt.Errorf("ensure git exclude: %w", err)
|
|
}
|
|
|
|
id := ulid.Make().String()
|
|
path := filepath.Join(dir, id+".jsonl")
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_EXCL, filePerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create session file: %w", err)
|
|
}
|
|
return &Store{Session: New(id), file: f}, nil
|
|
}
|
|
|
|
// Load liest eine existierende Session vollständig und hält die Datei zum
|
|
// Weiterschreiben offen.
|
|
func Load(repoRoot, id string) (*Store, error) {
|
|
dir := sessionsDir(repoRoot)
|
|
path := filepath.Join(dir, id+".jsonl")
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read session %s: %w", id, err)
|
|
}
|
|
|
|
sess := New(id)
|
|
for i, line := range bytes.Split(data, []byte("\n")) {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var rec record
|
|
if err := json.Unmarshal(line, &rec); err != nil {
|
|
return nil, fmt.Errorf("session %s: corrupt record at line %d: %w", id, i+1, err)
|
|
}
|
|
if rec.V != 1 {
|
|
return nil, fmt.Errorf("session %s: unsupported record version %d at line %d", id, rec.V, i+1)
|
|
}
|
|
if rec.Node != nil {
|
|
sess.Nodes[rec.Node.ID] = rec.Node
|
|
}
|
|
if rec.Head != "" {
|
|
sess.Head = rec.Head
|
|
}
|
|
}
|
|
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, filePerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open session %s for append: %w", id, err)
|
|
}
|
|
return &Store{Session: sess, file: f}, nil
|
|
}
|
|
|
|
func (st *Store) Close() error {
|
|
return st.file.Close()
|
|
}
|
|
|
|
// Append hängt den Node an den In-Memory-Baum an und schreibt ihn plus den
|
|
// neuen Head-Pointer als JSONL-Zeilen.
|
|
func (st *Store) Append(n *Node) error {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
if err := st.Session.Append(n); err != nil {
|
|
return err
|
|
}
|
|
if err := st.writeRecord(record{V: 1, Node: n}); err != nil {
|
|
return err
|
|
}
|
|
return st.writeRecord(record{V: 1, Head: st.Head})
|
|
}
|
|
|
|
func (st *Store) writeRecord(rec record) error {
|
|
data, err := json.Marshal(rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = st.file.Write(data)
|
|
return err
|
|
}
|
|
|
|
// Delete entfernt eine Sessiondatei unwiderruflich. Der Aufrufer muss
|
|
// sicherstellen, dass es nicht die gerade offene/aktive Session ist.
|
|
func Delete(repoRoot, id string) error {
|
|
path := filepath.Join(sessionsDir(repoRoot), id+".jsonl")
|
|
if err := os.Remove(path); err != nil {
|
|
return fmt.Errorf("delete session %s: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Info ist die Kurzübersicht für `nub sessions`.
|
|
type Info struct {
|
|
ID string
|
|
Head string
|
|
NodeCount int
|
|
Created time.Time
|
|
Summary string
|
|
}
|
|
|
|
// List liest alle Sessions unter .nub/sessions und liefert ihre Kurzübersicht,
|
|
// neueste zuerst. Beschädigte Sessiondateien werden übersprungen statt den
|
|
// gesamten Aufruf scheitern zu lassen.
|
|
func List(repoRoot string) ([]Info, error) {
|
|
dir := sessionsDir(repoRoot)
|
|
entries, err := os.ReadDir(dir)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var infos []Info
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(e.Name(), ".jsonl")
|
|
info, err := loadInfo(dir, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
infos = append(infos, info)
|
|
}
|
|
sort.Slice(infos, func(i, j int) bool { return infos[i].Created.After(infos[j].Created) })
|
|
return infos, nil
|
|
}
|
|
|
|
func loadInfo(dir, id string) (Info, error) {
|
|
data, err := os.ReadFile(filepath.Join(dir, id+".jsonl"))
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
|
|
info := Info{ID: id}
|
|
for _, line := range bytes.Split(data, []byte("\n")) {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var rec record
|
|
if err := json.Unmarshal(line, &rec); err != nil {
|
|
return Info{}, err
|
|
}
|
|
if rec.V != 1 {
|
|
return Info{}, fmt.Errorf("unsupported record version %d", rec.V)
|
|
}
|
|
if rec.Node != nil {
|
|
info.NodeCount++
|
|
if info.Created.IsZero() || rec.Node.Meta.CreatedAt.Before(info.Created) {
|
|
info.Created = rec.Node.Meta.CreatedAt
|
|
}
|
|
if info.Summary == "" && rec.Node.Message.Role == llm.RoleUser {
|
|
info.Summary = firstText(rec.Node.Message.Content)
|
|
}
|
|
}
|
|
if rec.Head != "" {
|
|
info.Head = rec.Head
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func firstText(blocks []llm.Block) string {
|
|
for _, b := range blocks {
|
|
if b.Kind == llm.KindText && b.Text != "" {
|
|
return truncate(b.Text, 80)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|