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