| 1 | package transcript |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "maps" |
| 7 | "reflect" |
| 8 | ) |
| 9 | |
| 10 | const snapshotCacheBytes = 64 << 20 |
| 11 | const snapshotCacheEntries = 3 |
| 12 | |
| 13 | type frozenSnapshot struct { |
| 14 | projection *Projection |
| 15 | bytes int |
| 16 | } |
| 17 | |
| 18 | func validateRecordIdentities(messages []*bufferedMessage) error { |
| 19 | seen := make(map[string]int, len(messages)) |
| 20 | for index, row := range messages { |
| 21 | id := row.message.RecordID |
| 22 | if id == "" { |
| 23 | return errors.New("transcript snapshot record identity is missing") |
| 24 | } |
| 25 | if previous, exists := seen[id]; exists { |
| 26 | return fmt.Errorf("transcript snapshot record identity %q is duplicated at %d and %d", id, previous, index) |
| 27 | } |
| 28 | seen[id] = index |
| 29 | } |
| 30 | return nil |
| 31 | } |
| 32 | |
| 33 | // freezeLocked retains a bounded number of immutable cuts. Strings already |
| 34 | // owned by settled rows are shared; only a live accumulator is materialized. |
| 35 | // Paging/content therefore continue to describe the requested cut while the |
| 36 | // live projection advances. |
| 37 | func (p *Projection) freezeLocked(id string) (*Projection, error) { |
| 38 | if id != "" { |
| 39 | if cached, ok := p.snapshots[id]; ok { |
| 40 | return cached.projection, nil |
| 41 | } |
| 42 | return nil, nil |
| 43 | } |
| 44 | id = p.boundaryLocked().SnapshotID |
| 45 | if cached, ok := p.snapshots[id]; ok { |
| 46 | return cached.projection, nil |
| 47 | } |
| 48 | frozen := &Projection{incarnation: p.incarnation, identity: p.identity, revision: p.revision, covered: p.covered, durable: p.durable, |
| 49 | runtime: p.runtime, attempts: maps.Clone(p.attempts), prompts: maps.Clone(p.prompts)} |
| 50 | frozen.buffer.userTurns = p.buffer.userTurns |
| 51 | used := 0 |
| 52 | for _, row := range p.buffer.messages { |
| 53 | message := row.materialize() |
| 54 | used += retainedBytes(reflect.ValueOf(message)) |
| 55 | copy := &bufferedMessage{message: message} |
| 56 | if message.Role == "assistant" { |
| 57 | copy.content.replace(message.Content) |
| 58 | copy.reasoning.replace(message.Reasoning) |
| 59 | } |
| 60 | frozen.buffer.messages = append(frozen.buffer.messages, copy) |
| 61 | } |
| 62 | // Reject a malformed projection before it becomes a reusable frozen cut. |
| 63 | // Empty or duplicate identities would alias pagination and content reads. |
| 64 | if err := validateRecordIdentities(frozen.buffer.messages); err != nil { |
| 65 | return nil, err |
| 66 | } |
| 67 | // The turn index is derived once per cut and shares this cut's lifetime, so |
| 68 | // paging the body never shrinks navigation and repeated outline reads reuse |
| 69 | // one pass. Its previews count against the same cache budget. |
| 70 | frozen.outline = buildOutline(frozen.buffer.messages) |
| 71 | used += retainedBytes(reflect.ValueOf(frozen.outline)) |
| 72 | runtime, _ := frozen.runtimeLocked() |
| 73 | used += retainedBytes(reflect.ValueOf(runtime)) |
| 74 | if p.snapshots == nil { |
| 75 | p.snapshots = make(map[string]frozenSnapshot) |
| 76 | } |
| 77 | // The current cut is pinned, including unusually large sessions. Settled |
| 78 | // body strings are shared with the projection; an oversized current cut |
| 79 | // evicts every older cut instead of making the conversation unreadable. |
| 80 | for len(p.snapshotOrder) > 0 && (len(p.snapshotOrder) >= snapshotCacheEntries || p.snapshotBytes+used > snapshotCacheBytes) { |
| 81 | oldest := p.snapshotOrder[0] |
| 82 | p.snapshotOrder = p.snapshotOrder[1:] |
| 83 | p.snapshotBytes -= p.snapshots[oldest].bytes |
| 84 | delete(p.snapshots, oldest) |
| 85 | } |
| 86 | p.snapshots[id] = frozenSnapshot{projection: frozen, bytes: used} |
| 87 | p.snapshotOrder = append(p.snapshotOrder, id) |
| 88 | p.snapshotBytes += used |
| 89 | return frozen, nil |
| 90 | } |
| 91 |