| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "unicode" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/session" |
| 14 | "reasonix/internal/textutil" |
| 15 | ) |
| 16 | |
| 17 | func nativeHistorySequence(p *agent.DisplayPager) uint64 { |
| 18 | if p.Header.RevisionKnown && p.Header.Revision > 0 { |
| 19 | return uint64(p.Header.Revision) |
| 20 | } |
| 21 | return 0 |
| 22 | } |
| 23 | |
| 24 | func nativeHistoryEntryID(r *desktopHistoryReader, p *agent.DisplayPager, position int) string { |
| 25 | return fmt.Sprintf("s%s:r%d:m%d:o0", strings.TrimSuffix(filepath.Base(r.path), ".jsonl"), nativeHistorySequence(p), position) |
| 26 | } |
| 27 | |
| 28 | func nativeNavigationPager(r *desktopHistoryReader) (*agent.DisplayPager, error) { |
| 29 | if r.native == nil { |
| 30 | return nil, agent.ErrDisplayFormatUnsupported |
| 31 | } |
| 32 | p, err := r.native.wait(r.ctx) |
| 33 | if err != nil { |
| 34 | return nil, err |
| 35 | } |
| 36 | p = p.WithContext(r.ctx) |
| 37 | return p, p.Validate() |
| 38 | } |
| 39 | |
| 40 | func nativeNavigationStatus(r *desktopHistoryReader, err error) string { |
| 41 | if r.ctx.Err() != nil || errors.Is(err, agent.ErrDisplaySourceChanged) { |
| 42 | return "stale_cursor" |
| 43 | } |
| 44 | if errors.Is(err, agent.ErrDisplayFormatUnsupported) { |
| 45 | return "unsupported" |
| 46 | } |
| 47 | return "failed" |
| 48 | } |
| 49 | |
| 50 | func nativeHistoryCursor(r *desktopHistoryReader, p *agent.DisplayPager, before int) string { |
| 51 | return encodeHistorySliceCursor(historySliceCursor{V: 1, Revision: p.Header.Revision, RevKnown: p.Header.RevisionKnown, |
| 52 | Digest: p.Header.ContentDigest, Before: before, Source: r.native.key}) |
| 53 | } |
| 54 | |
| 55 | func nativeHistoryLocation(r *desktopHistoryReader, p *agent.DisplayPager, messageID string, snapshot uint64) (session.MessageLocation, error) { |
| 56 | sequence := nativeHistorySequence(p) |
| 57 | location := session.MessageLocation{Status: "not_found", MessageID: messageID, Generation: r.native.key, SnapshotSequence: sequence, CoverageSequence: sequence} |
| 58 | if snapshot != 0 && snapshot != sequence { |
| 59 | location.Status = "stale_cursor" |
| 60 | return location, nil |
| 61 | } |
| 62 | position, sub, legacyRow, ok := parseHistoryEntryID(messageID) |
| 63 | // Cold outline identities name the first display row of a provider message. |
| 64 | // Require an exact round trip; permissive Sscanf parsing alone is not proof |
| 65 | // of this source, rewrite epoch, or an existing display identity. |
| 66 | if !ok || sub != 0 || legacyRow != -1 || position < 0 || messageID != nativeHistoryEntryID(r, p, position) { |
| 67 | return location, nil |
| 68 | } |
| 69 | entry, err := p.Entry(position) |
| 70 | if errors.Is(err, sql.ErrNoRows) { |
| 71 | return location, nil |
| 72 | } |
| 73 | if err != nil { |
| 74 | return location, err |
| 75 | } |
| 76 | location.Status, location.Position, location.VisibleTurn = "ready", int64(position), entry.AuthoredTurn |
| 77 | location.Cursor = nativeHistoryCursor(r, p, position+1) |
| 78 | return location, p.Validate() |
| 79 | } |
| 80 | |
| 81 | func (a *App) readNativeHistoryOutline(r *desktopHistoryReader, req session.HistoryOutlineRequest) (session.HistoryOutlinePage, error) { |
| 82 | page := session.HistoryOutlinePage{Entries: []session.HistoryOutlineEntry{}} |
| 83 | p, err := nativeNavigationPager(r) |
| 84 | if err != nil { |
| 85 | page.Status = nativeNavigationStatus(r, err) |
| 86 | if page.Status != "failed" { |
| 87 | err = nil |
| 88 | } |
| 89 | return page, err |
| 90 | } |
| 91 | page.Generation, page.SnapshotSequence, page.CoverageSequence = r.native.key, nativeHistorySequence(p), nativeHistorySequence(p) |
| 92 | page.TotalTurns, page.NextTurn = p.Header.AuthoredTurns, max(req.StartTurn, 1) |
| 93 | if req.Generation != "" && req.Generation != page.Generation || req.SnapshotSequence != nil && *req.SnapshotSequence != page.SnapshotSequence { |
| 94 | page.Status = "stale_cursor" |
| 95 | return page, nil |
| 96 | } |
| 97 | limit := req.Limit |
| 98 | if limit <= 0 { |
| 99 | limit = 128 |
| 100 | } |
| 101 | entries, err := p.TurnEntries(page.NextTurn, min(limit, 1000)) |
| 102 | if err != nil { |
| 103 | return page, err |
| 104 | } |
| 105 | for _, entry := range entries { |
| 106 | // Only requested prompt positions are read, one at a time. Never retain |
| 107 | // the entire transcript or decode tool/attachment records between turns. |
| 108 | var prompt string |
| 109 | if p.DAG || p.SchemaOne { |
| 110 | messages, readErr := p.EventMessages(entry.Index, entry.Index+1) |
| 111 | err = readErr |
| 112 | if err == nil && len(messages) == 1 { |
| 113 | prompt = agent.UserMessageText(messages[0]) |
| 114 | } |
| 115 | } else { |
| 116 | messages, readErr := readSessionMessagesAtOffsetsContext(r.ctx, r.path, []agent.DisplayIndexEntry{entry}) |
| 117 | err = readErr |
| 118 | if err == nil && len(messages) == 1 { |
| 119 | prompt = agent.UserMessageText(messages[0]) |
| 120 | } |
| 121 | } |
| 122 | if err != nil { |
| 123 | return page, err |
| 124 | } |
| 125 | prompt, err = nativeHistoryPrompt(r.ctx, prompt) |
| 126 | if err != nil { |
| 127 | return page, err |
| 128 | } |
| 129 | page.Entries = append(page.Entries, session.HistoryOutlineEntry{MessageID: nativeHistoryEntryID(r, p, entry.Index), Turn: entry.AuthoredTurn, Position: int64(entry.Index), Prompt: prompt}) |
| 130 | page.NextTurn = entry.AuthoredTurn + 1 |
| 131 | } |
| 132 | if err := p.Validate(); err != nil { |
| 133 | return session.HistoryOutlinePage{Entries: []session.HistoryOutlineEntry{}, Status: nativeNavigationStatus(r, err)}, err |
| 134 | } |
| 135 | page.Status, page.Done = "ready", page.NextTurn > page.TotalTurns |
| 136 | return page, nil |
| 137 | } |
| 138 | |
| 139 | // Normalize only a bounded prefix. strings.Fields over a multi-megabyte |
| 140 | // prompt would allocate an array for every word before clipping the preview. |
| 141 | func nativeHistoryPrompt(ctx context.Context, text string) (string, error) { |
| 142 | var out strings.Builder |
| 143 | space, count, nextCheck := false, 0, 0 |
| 144 | for offset, ch := range text { |
| 145 | if offset >= nextCheck { |
| 146 | if err := ctx.Err(); err != nil { |
| 147 | return "", err |
| 148 | } |
| 149 | nextCheck = offset + (64 << 10) |
| 150 | } |
| 151 | if unicode.IsSpace(ch) { |
| 152 | space = out.Len() > 0 |
| 153 | continue |
| 154 | } |
| 155 | if space { |
| 156 | out.WriteByte(' ') |
| 157 | space = false |
| 158 | } |
| 159 | out.WriteRune(ch) |
| 160 | count++ |
| 161 | if count >= 512 { |
| 162 | out.WriteString("…") |
| 163 | break |
| 164 | } |
| 165 | } |
| 166 | return textutil.ClipGraphemes(out.String(), 50, "…"), ctx.Err() |
| 167 | } |
| 168 | |
| 169 | // Resolve a direct jump using the same generation as paging and outline. |
| 170 | // Cursor navigation never walks pages from the newest end to find a target. |
| 171 | func nativeHistorySliceAnchor(r *desktopHistoryReader, p *agent.DisplayPager, req HistorySliceRequest) (HistorySliceRequest, string, error) { |
| 172 | if err := p.WithContext(r.ctx).Validate(); err != nil { |
| 173 | return req, nativeNavigationStatus(r, err), err |
| 174 | } |
| 175 | if req.Generation != "" && req.Generation != r.native.key || req.SnapshotSequence != nil && *req.SnapshotSequence != nativeHistorySequence(p) { |
| 176 | return req, "stale_cursor", nil |
| 177 | } |
| 178 | switch req.Anchor { |
| 179 | case "", "newest", "cursor": |
| 180 | return req, "ready", nil |
| 181 | case "turn": |
| 182 | entries, err := p.WithContext(r.ctx).TurnEntries(max(req.Turn, 1), 1) |
| 183 | if err != nil { |
| 184 | return req, "failed", err |
| 185 | } |
| 186 | if len(entries) == 0 || entries[0].AuthoredTurn != max(req.Turn, 1) { |
| 187 | return req, "not_found", nil |
| 188 | } |
| 189 | req.Cursor = nativeHistoryCursor(r, p, entries[0].Index+1) |
| 190 | case "message": |
| 191 | location, err := nativeHistoryLocation(r, p.WithContext(r.ctx), req.MessageID, 0) |
| 192 | if err != nil || location.Status != "ready" { |
| 193 | return req, location.Status, err |
| 194 | } |
| 195 | req.Cursor = location.Cursor |
| 196 | default: |
| 197 | return req, "unsupported", nil |
| 198 | } |
| 199 | req.Newer = false |
| 200 | return req, "ready", nil |
| 201 | } |
| 202 |