Files
Agent-Native/runtime/session/compact.go
T
weichenandCursor 6bc1b08540 Record sessions as an append-only entry tree
Why: Linear transcripts cannot fork, and compaction rewrote history, so resume and branch navigation had nothing to point at.
Evidence: go test -count=1 ./runtime/session/
Impact: Load still returns the model-visible path, including old one-message-per-line files. Compaction appends a summary entry instead of truncating the file.
Refs: feature/pi-headless N1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:08:04 +00:00

119 lines
2.8 KiB
Go

package session
import (
"bufio"
"encoding/json"
"os"
"agent-native/runtime/llm"
)
// Stats 是转录文件的轻量元数据(扫描即得,无副作用)。
type Stats struct {
Lines int
Bytes int64
}
// Stats 统计当前转录行数与字节数(空行不计)。
func (s *Store) Stats() Stats {
s.mu.Lock()
defer s.mu.Unlock()
st := Stats{}
f, err := os.Open(s.path)
if err != nil {
return st
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
if len(sc.Bytes()) == 0 {
continue
}
st.Lines++
}
if info, err := f.Stat(); err == nil {
st.Bytes = info.Size()
}
return st
}
// SaveMeta 把会话元数据写进 <path>.meta.json(创建时间/模型等由调用方定义)。
func (s *Store) SaveMeta(meta map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
tmp := s.path + ".meta.json.tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path+".meta.json")
}
// ReadMeta 读取会话元数据;不存在时返回 nil(新会话)。
func (s *Store) ReadMeta() map[string]string {
raw, err := os.ReadFile(s.path + ".meta.json")
if err != nil {
return nil
}
var meta map[string]string
if err := json.Unmarshal(raw, &meta); err != nil {
return nil
}
return meta
}
// Compact appends a compaction entry. The file keeps every earlier record.
// Load hides the summarized prefix behind the summary plus the last
// keepLastN non-system messages. Returns false when there is nothing to fold.
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
s.mu.Lock()
entries, err := s.readEntriesLocked()
s.mu.Unlock()
if err != nil {
return false, err
}
leaf := ""
s.mu.Lock()
leaf = s.leafLocked(entries)
s.mu.Unlock()
path := pathTo(entries, leaf)
var messages []llm.Message
var msgEntries []Entry
for _, e := range path {
if e.Type == TypeMessage && e.Message != nil {
messages = append(messages, *e.Message)
msgEntries = append(msgEntries, e)
}
}
if len(messages) <= keepLastN+1 {
return false, nil
}
rest := messages
restEntries := msgEntries
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
rest = rest[1:]
restEntries = restEntries[1:]
}
tailStart := len(rest) - keepLastN
if tailStart <= 0 {
return false, nil
}
head := rest[:tailStart]
summary, err := summarize(head)
if err != nil {
return false, err
}
var tailIDs []string
for _, e := range restEntries[tailStart:] {
tailIDs = append(tailIDs, e.ID)
}
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs})
return err == nil, err
}