| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/store" |
| 12 | ) |
| 13 | |
| 14 | // ContentDigest returns the canonical digest used by the session WAL and |
| 15 | // revision ledger for the current in-memory transcript. |
| 16 | func (s *Session) ContentDigest() (string, error) { |
| 17 | if s == nil { |
| 18 | return "", fmt.Errorf("nil session") |
| 19 | } |
| 20 | return ContentDigestForMessages(s.Snapshot()) |
| 21 | } |
| 22 | |
| 23 | // ContentDigestForMessages returns the canonical transcript digest for an |
| 24 | // immutable message snapshot. Frontends use it to bind a rendered history page |
| 25 | // to the exact content it contains instead of sampling a sidecar revision that |
| 26 | // may have advanced before or after the page was built. |
| 27 | func ContentDigestForMessages(msgs []provider.Message) (string, error) { |
| 28 | digest, err := digestSessionMessages(msgs) |
| 29 | if err != nil { |
| 30 | return "", err |
| 31 | } |
| 32 | return digestString(digest), nil |
| 33 | } |
| 34 | |
| 35 | // SessionsShareContent reports whether two saved sessions decode to the same |
| 36 | // transcript. It replaces byte-comparing the .jsonl checkpoints, which stopped |
| 37 | // implying transcript equality once the event log became authoritative: two |
| 38 | // identical checkpoints can hide diverged event logs. |
| 39 | func SessionsShareContent(pathA, pathB string) (bool, error) { |
| 40 | msgsA, _, _, err := loadSessionMessages(pathA) |
| 41 | if err != nil { |
| 42 | return false, err |
| 43 | } |
| 44 | msgsB, _, _, err := loadSessionMessages(pathB) |
| 45 | if err != nil { |
| 46 | return false, err |
| 47 | } |
| 48 | digestA, err := digestSessionMessages(msgsA) |
| 49 | if err != nil { |
| 50 | return false, err |
| 51 | } |
| 52 | digestB, err := digestSessionMessages(msgsB) |
| 53 | if err != nil { |
| 54 | return false, err |
| 55 | } |
| 56 | return bytes.Equal(digestA[:], digestB[:]), nil |
| 57 | } |
| 58 | |
| 59 | // SessionUserMessage is one complete user-role message with the best-known |
| 60 | // wall-clock time. Keeping the provider.Message preserves durable origin and |
| 61 | // RawContent so current display/history consumers never fall back to text |
| 62 | // prefixes. Messages restored from a replace event (compaction, rewind) lose |
| 63 | // their per-turn times and report zero; callers apply their own fallback. |
| 64 | type SessionUserMessage struct { |
| 65 | Message provider.Message |
| 66 | At time.Time |
| 67 | } |
| 68 | |
| 69 | // LoadSessionUserMessages returns the session's user-role messages in |
| 70 | // transcript order, event-log aware. Direct .jsonl decoding misses everything |
| 71 | // after the first save once an event log exists, so surfaces like prompt |
| 72 | // history must use this instead. |
| 73 | func LoadSessionUserMessages(path string) ([]SessionUserMessage, error) { |
| 74 | return loadSessionUserMessagesWithLimits(path, defaultSessionReplayLimits) |
| 75 | } |
| 76 | |
| 77 | func loadSessionUserMessagesWithLimits(path string, limits sessionReplayLimits) ([]SessionUserMessage, error) { |
| 78 | res, err := loadSessionTranscript(context.Background(), path, limits, nil) |
| 79 | if err != nil { |
| 80 | return nil, err |
| 81 | } |
| 82 | out := make([]SessionUserMessage, 0, len(res.msgs)) |
| 83 | for i, m := range res.msgs { |
| 84 | if m.Role != provider.RoleUser || IsPinnedContextRevision(m) { |
| 85 | continue |
| 86 | } |
| 87 | at := time.Time{} |
| 88 | if i < len(res.times) { |
| 89 | at = res.times[i] |
| 90 | } |
| 91 | if m.CreatedAt > 0 { |
| 92 | at = time.UnixMilli(m.CreatedAt) |
| 93 | } |
| 94 | out = append(out, SessionUserMessage{Message: m, At: at}) |
| 95 | } |
| 96 | return out, nil |
| 97 | } |
| 98 | |
| 99 | // SessionContentModTime returns when the session transcript last changed on |
| 100 | // disk: the newer of the .jsonl checkpoint and the event log. The checkpoint |
| 101 | // alone goes stale between checkpoints, so recency ordering must use this. |
| 102 | func SessionContentModTime(path string) time.Time { |
| 103 | var mod time.Time |
| 104 | if info, err := os.Stat(path); err == nil && !info.IsDir() { |
| 105 | mod = info.ModTime() |
| 106 | } |
| 107 | if logPath := store.SessionEventLog(path); logPath != "" { |
| 108 | if info, err := os.Stat(logPath); err == nil && !info.IsDir() && info.ModTime().After(mod) { |
| 109 | mod = info.ModTime() |
| 110 | } |
| 111 | } |
| 112 | return mod |
| 113 | } |
| 114 |