ScuttleBot

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

Keyboard Shortcuts

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