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/.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] + "…" }