1package core
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15 "time"
16 "unicode"
17
18 "github.com/bnema/zut/packages/provider"
19)
20
21const (
22 maxSessionSearchRowBytes = 1 << 20
23 maxSessionSearchRunes = 512
24)
25
26// SessionSearchSegment is one bounded, display-safe user or assistant text
27// excerpt from a persisted session. It deliberately never contains tool,
28// developer, image, or raw JSON data.
29type SessionSearchSegment struct {
30 Path string
31 Text string
32 Normalized string
33 Role provider.Role
34 Time time.Time
35 Order int
36}
37
38// ListSessionPathsContext lists eligible session files for cwd or, when all is
39// true, for every cwd bucket in root's active session namespace. It ignores
40// symlinks, malformed buckets, files with invalid metadata, and files whose
41// stored cwd does not hash to the directory that contains them.
42func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []string {
43 if err := contextErr(ctx); err != nil || strings.TrimSpace(root) == "" {
44 return nil
45 }
46
47 var dirs []string
48 if all {
49 sessionsDir := filepath.Join(root, "sessions")
50 entries, err := os.ReadDir(sessionsDir)
51 if err != nil {
52 return nil
53 }
54 for _, entry := range entries {
55 if err := contextErr(ctx); err != nil {
56 return nil
57 }
58 if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !isSessionBucketName(entry.Name()) {
59 continue
60 }
61 dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))
62 }
63 } else {
64 dir := SessionsDir(root, cwd)
65 info, err := os.Lstat(dir)
66 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
67 return nil
68 }
69 dirs = []string{dir}
70 }
71
72 type candidate struct {
73 path string
74 mod time.Time
75 }
76 var candidates []candidate
77 for _, dir := range dirs {
78 if err := contextErr(ctx); err != nil {
79 return nil
80 }
81 entries, err := os.ReadDir(dir)
82 if err != nil {
83 continue
84 }
85 for _, entry := range entries {
86 if err := contextErr(ctx); err != nil {
87 return nil
88 }
89 if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
90 continue
91 }
92 info, err := entry.Info()
93 if err != nil || !info.Mode().IsRegular() {
94 continue
95 }
96 path := filepath.Join(dir, entry.Name())
97 meta, err := scanSessionMetaContext(ctx, path)
98 if err != nil || meta.ID == "" || meta.CWD == "" || meta.HideFromSessions {
99 continue
100 }
101 if filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(dir) {
102 continue
103 }
104 if !all && meta.CWD != cwd {
105 continue
106 }
107 candidates = append(candidates, candidate{path: path, mod: info.ModTime()})
108 }
109 }
110 sort.Slice(candidates, func(i, j int) bool {
111 if !candidates[i].mod.Equal(candidates[j].mod) {
112 return candidates[i].mod.After(candidates[j].mod)
113 }
114 return candidates[i].path > candidates[j].path
115 })
116 paths := make([]string, len(candidates))
117 for i, candidate := range candidates {
118 paths[i] = candidate.path
119 }
120 return paths
121}
122
123// ManagedSessionMeta validates that path is a regular session file directly
124// owned by root's active namespace and returns its latest metadata. It is the
125// trust boundary for a picker selection before a host changes workspace state.
126func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, error) {
127 if err := contextErr(ctx); err != nil {
128 return SessionMeta{}, err
129 }
130 if strings.TrimSpace(root) == "" || strings.TrimSpace(path) == "" {
131 return SessionMeta{}, errors.New("managed session: root or path is empty")
132 }
133 info, err := os.Lstat(path)
134 if err != nil {
135 return SessionMeta{}, fmt.Errorf("managed session: stat: %w", err)
136 }
137 if !info.Mode().IsRegular() {
138 return SessionMeta{}, errors.New("managed session: path is not a regular file")
139 }
140 meta, err := scanSessionMetaContext(ctx, path)
141 if err != nil {
142 return SessionMeta{}, err
143 }
144 if meta.ID == "" || meta.CWD == "" {
145 return SessionMeta{}, errors.New("managed session: invalid session metadata")
146 }
147 wantDir := filepath.Clean(SessionsDir(root, meta.CWD))
148 dirInfo, err := os.Lstat(wantDir)
149 if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() {
150 return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory")
151 }
152 if filepath.Clean(filepath.Dir(path)) != wantDir {
153 return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")
154 }
155 return meta, nil
156}
157
158// ReadSessionSearchSegments extracts bounded searchable text from persisted
159// message rows. It keeps historical user/assistant rows after compaction but
160// excludes compaction payloads and every non-text content block.
161func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearchSegment, error) {
162 if err := contextErr(ctx); err != nil {
163 return nil, err
164 }
165 file, err := os.Open(path)
166 if err != nil {
167 return nil, fmt.Errorf("read session search text: open: %w", err)
168 }
169 defer file.Close()
170
171 reader := bufio.NewReaderSize(file, 64*1024)
172 var segments []SessionSearchSegment
173 order := 0
174 for {
175 if err := contextErr(ctx); err != nil {
176 return nil, err
177 }
178 line, err := readBoundedSessionSearchLine(reader)
179 if err != nil && !errors.Is(err, io.EOF) {
180 return nil, fmt.Errorf("read session search text: %w", err)
181 }
182 if len(line) > 0 {
183 var head sessionLineHead
184 if jsonErr := json.Unmarshal(line, &head); jsonErr == nil && head.Type == "message" {
185 message, messageErr := readSearchMessage(line)
186 if messageErr == nil && (message.Role == provider.RoleUser || message.Role == provider.RoleAssistant) {
187 for _, rawText := range message.Texts {
188 for _, text := range splitSessionSearchText(rawText) {
189 segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order})
190 order++
191 }
192 }
193 }
194 }
195 }
196 if errors.Is(err, io.EOF) {
197 break
198 }
199 }
200 return segments, nil
201}
202
203type sessionSearchMessage struct {
204 Role provider.Role
205 Time time.Time
206 Texts []string
207}
208
209func readSearchMessage(line []byte) (sessionSearchMessage, error) {
210 var row struct {
211 Message json.RawMessage `json:"message"`
212 }
213 if err := json.Unmarshal(line, &row); err != nil {
214 return sessionSearchMessage{}, err
215 }
216 if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
217 return sessionSearchMessage{}, errors.New("message row has no message")
218 }
219 var message struct {
220 Role provider.Role `json:"role"`
221 Time time.Time `json:"time"`
222 Content []json.RawMessage `json:"content"`
223 }
224 if err := json.Unmarshal(row.Message, &message); err != nil {
225 return sessionSearchMessage{}, err
226 }
227 if message.Role != provider.RoleUser && message.Role != provider.RoleAssistant && message.Role != provider.RoleTool && message.Role != provider.RoleDeveloper {
228 return sessionSearchMessage{}, fmt.Errorf("message has invalid role %q", message.Role)
229 }
230 if len(message.Content) == 0 {
231 return sessionSearchMessage{}, errors.New("message has no content")
232 }
233 result := sessionSearchMessage{Role: message.Role, Time: message.Time}
234 for _, rawContent := range message.Content {
235 var textBlock struct {
236 Text string `json:"text"`
237 }
238 if err := json.Unmarshal(rawContent, &textBlock); err != nil {
239 return sessionSearchMessage{}, err
240 }
241 if textBlock.Text != "" {
242 result.Texts = append(result.Texts, textBlock.Text)
243 }
244 }
245 return result, nil
246}
247
248func readBoundedSessionSearchLine(reader *bufio.Reader) ([]byte, error) {
249 var line []byte
250 for {
251 fragment, err := reader.ReadSlice('\n')
252 line = append(line, fragment...)
253 if len(line) > maxSessionSearchRowBytes {
254 return nil, fmt.Errorf("JSONL row exceeds %d bytes", maxSessionSearchRowBytes)
255 }
256 switch {
257 case err == nil:
258 return bytes.TrimRight(line, "\r\n"), nil
259 case errors.Is(err, bufio.ErrBufferFull):
260 continue
261 case errors.Is(err, io.EOF):
262 return bytes.TrimRight(line, "\r\n"), io.EOF
263 default:
264 return nil, err
265 }
266 }
267}
268
269func splitSessionSearchText(text string) []string {
270 text = sanitizeSessionSearchText(text)
271 if text == "" {
272 return nil
273 }
274 runes := []rune(text)
275 if len(runes) <= maxSessionSearchRunes {
276 return []string{text}
277 }
278 segments := make([]string, 0, len(runes)/maxSessionSearchRunes+1)
279 for len(runes) > 0 {
280 end := min(len(runes), maxSessionSearchRunes)
281 if end < len(runes) {
282 for split := end - 1; split > end/2; split-- {
283 if unicode.IsSpace(runes[split]) || unicode.Is(unicode.Sentence_Terminal, runes[split]) {
284 end = split + 1
285 break
286 }
287 }
288 }
289 segment := strings.TrimSpace(string(runes[:end]))
290 if segment != "" {
291 segments = append(segments, segment)
292 }
293 runes = runes[end:]
294 }
295 return segments
296}
297
298// NormalizeSessionSearchText returns the display-safe case-folded form used
299// by the picker matcher. It removes terminal and bidi controls and collapses
300// whitespace without retaining any raw transcript representation.
301func NormalizeSessionSearchText(text string) string {
302 return strings.ToLower(sanitizeSessionSearchText(text))
303}
304
305func sanitizeSessionSearchText(text string) string {
306 var out strings.Builder
307 out.Grow(len(text))
308 space := true
309 for _, r := range text {
310 if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.Is(unicode.Cf, r) {
311 continue
312 }
313 if unicode.IsSpace(r) {
314 if !space {
315 out.WriteByte(' ')
316 space = true
317 }
318 continue
319 }
320 out.WriteRune(r)
321 space = false
322 }
323 return strings.TrimSpace(out.String())
324}