ScuttleBot

scuttlebot / pkg / relaymirror / session.go
Source Blame History 123 lines
3be3167… noreply 1 package relaymirror
3be3167… noreply 2
3be3167… noreply 3 import (
3be3167… noreply 4 "context"
3be3167… noreply 5 "encoding/json"
3be3167… noreply 6 "fmt"
3be3167… noreply 7 "os"
3be3167… noreply 8 "path/filepath"
3be3167… noreply 9 "sort"
3be3167… noreply 10 "strings"
3be3167… noreply 11 "time"
3be3167… noreply 12 )
3be3167… noreply 13
3be3167… noreply 14 // SessionWatcher watches a directory for new session files and calls onFile
3be3167… noreply 15 // when one is discovered. Designed for Gemini CLI session discovery.
3be3167… noreply 16 type SessionWatcher struct {
3be3167… noreply 17 dir string
3be3167… noreply 18 prefix string // e.g. "session-"
3be3167… noreply 19 timeout time.Duration
3be3167… noreply 20 }
3be3167… noreply 21
3be3167… noreply 22 // NewSessionWatcher creates a watcher for session files matching prefix in dir.
3be3167… noreply 23 func NewSessionWatcher(dir, prefix string, timeout time.Duration) *SessionWatcher {
3be3167… noreply 24 return &SessionWatcher{dir: dir, prefix: prefix, timeout: timeout}
3be3167… noreply 25 }
3be3167… noreply 26
3be3167… noreply 27 // Discover waits for a new session file to appear in the directory.
3be3167… noreply 28 // Returns the path of the discovered file.
3be3167… noreply 29 func (w *SessionWatcher) Discover(ctx context.Context, existingFiles map[string]bool) (string, error) {
3be3167… noreply 30 deadline := time.After(w.timeout)
3be3167… noreply 31 tick := time.NewTicker(500 * time.Millisecond)
3be3167… noreply 32 defer tick.Stop()
3be3167… noreply 33
3be3167… noreply 34 for {
3be3167… noreply 35 select {
3be3167… noreply 36 case <-ctx.Done():
3be3167… noreply 37 return "", ctx.Err()
3be3167… noreply 38 case <-deadline:
3be3167… noreply 39 return "", fmt.Errorf("session discovery timed out after %s", w.timeout)
3be3167… noreply 40 case <-tick.C:
3be3167… noreply 41 entries, err := os.ReadDir(w.dir)
3be3167… noreply 42 if err != nil {
3be3167… noreply 43 continue
3be3167… noreply 44 }
3be3167… noreply 45 // Find newest file matching prefix that isn't pre-existing.
3be3167… noreply 46 var candidates []os.DirEntry
3be3167… noreply 47 for _, e := range entries {
3be3167… noreply 48 if e.IsDir() || !strings.HasPrefix(e.Name(), w.prefix) {
3be3167… noreply 49 continue
3be3167… noreply 50 }
3be3167… noreply 51 if existingFiles[e.Name()] {
3be3167… noreply 52 continue
3be3167… noreply 53 }
3be3167… noreply 54 candidates = append(candidates, e)
3be3167… noreply 55 }
3be3167… noreply 56 if len(candidates) == 0 {
3be3167… noreply 57 continue
3be3167… noreply 58 }
3be3167… noreply 59 // Sort by mod time, pick newest.
3be3167… noreply 60 sort.Slice(candidates, func(i, j int) bool {
3be3167… noreply 61 ii, _ := candidates[i].Info()
3be3167… noreply 62 jj, _ := candidates[j].Info()
3be3167… noreply 63 if ii == nil || jj == nil {
3be3167… noreply 64 return false
3be3167… noreply 65 }
3be3167… noreply 66 return ii.ModTime().After(jj.ModTime())
3be3167… noreply 67 })
3be3167… noreply 68 return filepath.Join(w.dir, candidates[0].Name()), nil
3be3167… noreply 69 }
3be3167… noreply 70 }
3be3167… noreply 71 }
3be3167… noreply 72
3be3167… noreply 73 // SnapshotDir returns a set of filenames currently in dir.
3be3167… noreply 74 func SnapshotDir(dir string) map[string]bool {
3be3167… noreply 75 entries, err := os.ReadDir(dir)
3be3167… noreply 76 if err != nil {
3be3167… noreply 77 return nil
3be3167… noreply 78 }
3be3167… noreply 79 out := make(map[string]bool, len(entries))
3be3167… noreply 80 for _, e := range entries {
3be3167… noreply 81 out[e.Name()] = true
3be3167… noreply 82 }
3be3167… noreply 83 return out
3be3167… noreply 84 }
3be3167… noreply 85
3be3167… noreply 86 // GeminiMessage is a message from a Gemini CLI session file.
3be3167… noreply 87 type GeminiMessage struct {
3be3167… noreply 88 Type string `json:"type"` // "user", "gemini"
3be3167… noreply 89 Content string `json:"content,omitempty"`
3be3167… noreply 90 ToolCalls []GeminiToolCall `json:"toolCalls,omitempty"`
3be3167… noreply 91 }
3be3167… noreply 92
3be3167… noreply 93 // GeminiToolCall is a tool call in a Gemini session.
3be3167… noreply 94 type GeminiToolCall struct {
3be3167… noreply 95 Name string `json:"name"`
3be3167… noreply 96 Args json.RawMessage `json:"args"`
3be3167… noreply 97 Result json.RawMessage `json:"result,omitempty"`
3be3167… noreply 98 Status string `json:"status"`
3be3167… noreply 99 }
3be3167… noreply 100
3be3167… noreply 101 // GeminiSession is the top-level structure of a Gemini session file.
3be3167… noreply 102 type GeminiSession struct {
3be3167… noreply 103 SessionID string `json:"sessionId"`
3be3167… noreply 104 Messages []GeminiMessage `json:"messages"`
3be3167… noreply 105 }
3be3167… noreply 106
3be3167… noreply 107 // PollGeminiSession reads a Gemini session file and returns messages since
3be3167… noreply 108 // the given index. Returns the new message count.
3be3167… noreply 109 func PollGeminiSession(path string, sinceIdx int) ([]GeminiMessage, int, error) {
3be3167… noreply 110 data, err := os.ReadFile(path)
3be3167… noreply 111 if err != nil {
3be3167… noreply 112 return nil, sinceIdx, err
3be3167… noreply 113 }
3be3167… noreply 114 var session GeminiSession
3be3167… noreply 115 if err := json.Unmarshal(data, &session); err != nil {
3be3167… noreply 116 return nil, sinceIdx, err
3be3167… noreply 117 }
3be3167… noreply 118 if len(session.Messages) <= sinceIdx {
3be3167… noreply 119 return nil, sinceIdx, nil
3be3167… noreply 120 }
3be3167… noreply 121 newMsgs := session.Messages[sinceIdx:]
3be3167… noreply 122 return newMsgs, len(session.Messages), nil
3be3167… noreply 123 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button