package session import ( "bufio" "encoding/json" "fmt" "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 把会话元数据写进 .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 } const maxCompactAttempts = 3 // 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. // A session change during summary is retried from the new transcript. // Three consecutive summary failures or conflicts trip the breaker and // leave the file unchanged. func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) { var last error for attempt := 0; attempt < maxCompactAttempts; attempt++ { done, err, retry := s.compactOnce(keepLastN, summarize) if !retry { return done, err } last = err } if last == nil { last = fmt.Errorf("compaction conflict: session changed during summary") } return false, fmt.Errorf("compaction circuit open: %w", last) } func (s *Store) compactOnce(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error, bool) { s.mu.Lock() entries, err := s.readEntriesLocked() if err != nil { s.mu.Unlock() return false, err, false } leaf := s.leafLocked(entries) generation := len(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, false } 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, false } tailStart = alignToolTail(rest, tailStart) head := rest[:tailStart] summary, err := summarize(head) if err != nil { return false, err, true } var tailIDs []string for _, e := range restEntries[tailStart:] { tailIDs = append(tailIDs, e.ID) } s.mu.Lock() defer s.mu.Unlock() now, err := s.readEntriesLocked() if err != nil { return false, err, false } if len(now) != generation { return false, fmt.Errorf("compaction conflict: session changed during summary"), true } _, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs}) return err == nil, err, false } // alignToolTail 让保留尾巴从一条非 tool 消息开始,避免把 tool_calls 和结果拆开。 func alignToolTail(messages []llm.Message, tailStart int) int { for tailStart > 0 && messages[tailStart].Role == llm.RoleTool { tailStart-- } return tailStart }