|
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
|
|