|
016a29f…
|
lmata
|
1 |
package ircagent |
|
016a29f…
|
lmata
|
2 |
|
|
016a29f…
|
lmata
|
3 |
import ( |
|
016a29f…
|
lmata
|
4 |
"bytes" |
|
016a29f…
|
lmata
|
5 |
"context" |
|
016a29f…
|
lmata
|
6 |
"encoding/json" |
|
016a29f…
|
lmata
|
7 |
"fmt" |
|
016a29f…
|
lmata
|
8 |
"io" |
|
016a29f…
|
lmata
|
9 |
"log/slog" |
|
016a29f…
|
lmata
|
10 |
"net" |
|
016a29f…
|
lmata
|
11 |
"net/http" |
|
016a29f…
|
lmata
|
12 |
"strconv" |
|
016a29f…
|
lmata
|
13 |
"strings" |
|
016a29f…
|
lmata
|
14 |
"sync" |
|
016a29f…
|
lmata
|
15 |
"time" |
|
016a29f…
|
lmata
|
16 |
|
|
016a29f…
|
lmata
|
17 |
"github.com/conflicthq/scuttlebot/internal/llm" |
|
016a29f…
|
lmata
|
18 |
"github.com/lrstanley/girc" |
|
016a29f…
|
lmata
|
19 |
) |
|
016a29f…
|
lmata
|
20 |
|
|
016a29f…
|
lmata
|
21 |
const ( |
|
016a29f…
|
lmata
|
22 |
defaultHistoryLen = 20 |
|
016a29f…
|
lmata
|
23 |
defaultTypingDelay = 400 * time.Millisecond |
|
016a29f…
|
lmata
|
24 |
defaultErrorJoiner = " - " |
|
016a29f…
|
lmata
|
25 |
defaultGatewayTimout = 60 * time.Second |
|
016a29f…
|
lmata
|
26 |
) |
|
016a29f…
|
lmata
|
27 |
|
|
016a29f…
|
lmata
|
28 |
var defaultActivityPrefixes = []string{"claude-", "codex-", "gemini-"} |
|
016a29f…
|
lmata
|
29 |
|
|
016a29f…
|
lmata
|
30 |
// DefaultActivityPrefixes returns the default set of nick prefixes treated as |
|
016a29f…
|
lmata
|
31 |
// status/activity senders rather than chat participants. |
|
016a29f…
|
lmata
|
32 |
func DefaultActivityPrefixes() []string { |
|
016a29f…
|
lmata
|
33 |
return append([]string(nil), defaultActivityPrefixes...) |
|
016a29f…
|
lmata
|
34 |
} |
|
016a29f…
|
lmata
|
35 |
|
|
016a29f…
|
lmata
|
36 |
// Config configures the shared IRC agent runtime. |
|
016a29f…
|
lmata
|
37 |
type Config struct { |
|
016a29f…
|
lmata
|
38 |
IRCAddr string |
|
016a29f…
|
lmata
|
39 |
Nick string |
|
016a29f…
|
lmata
|
40 |
Pass string |
|
016a29f…
|
lmata
|
41 |
Channels []string |
|
016a29f…
|
lmata
|
42 |
SystemPrompt string |
|
016a29f…
|
lmata
|
43 |
Logger *slog.Logger |
|
016a29f…
|
lmata
|
44 |
HistoryLen int |
|
016a29f…
|
lmata
|
45 |
TypingDelay time.Duration |
|
016a29f…
|
lmata
|
46 |
ErrorJoiner string |
|
016a29f…
|
lmata
|
47 |
ActivityPrefixes []string |
|
016a29f…
|
lmata
|
48 |
Direct *DirectConfig |
|
016a29f…
|
lmata
|
49 |
Gateway *GatewayConfig |
|
016a29f…
|
lmata
|
50 |
} |
|
016a29f…
|
lmata
|
51 |
|
|
016a29f…
|
lmata
|
52 |
// DirectConfig configures direct provider mode. |
|
016a29f…
|
lmata
|
53 |
type DirectConfig struct { |
|
016a29f…
|
lmata
|
54 |
Backend string |
|
016a29f…
|
lmata
|
55 |
APIKey string |
|
016a29f…
|
lmata
|
56 |
Model string |
|
016a29f…
|
lmata
|
57 |
} |
|
016a29f…
|
lmata
|
58 |
|
|
016a29f…
|
lmata
|
59 |
// GatewayConfig configures scuttlebot gateway mode. |
|
016a29f…
|
lmata
|
60 |
type GatewayConfig struct { |
|
016a29f…
|
lmata
|
61 |
APIURL string |
|
016a29f…
|
lmata
|
62 |
Token string |
|
016a29f…
|
lmata
|
63 |
Backend string |
|
016a29f…
|
lmata
|
64 |
HTTPClient *http.Client |
|
016a29f…
|
lmata
|
65 |
} |
|
016a29f…
|
lmata
|
66 |
|
|
016a29f…
|
lmata
|
67 |
type historyEntry struct { |
|
016a29f…
|
lmata
|
68 |
role string |
|
016a29f…
|
lmata
|
69 |
nick string |
|
016a29f…
|
lmata
|
70 |
content string |
|
016a29f…
|
lmata
|
71 |
} |
|
016a29f…
|
lmata
|
72 |
|
|
016a29f…
|
lmata
|
73 |
type completer interface { |
|
016a29f…
|
lmata
|
74 |
complete(ctx context.Context, prompt string) (string, error) |
|
016a29f…
|
lmata
|
75 |
} |
|
016a29f…
|
lmata
|
76 |
|
|
016a29f…
|
lmata
|
77 |
type directCompleter struct { |
|
016a29f…
|
lmata
|
78 |
provider llm.Provider |
|
016a29f…
|
lmata
|
79 |
} |
|
016a29f…
|
lmata
|
80 |
|
|
016a29f…
|
lmata
|
81 |
func (d *directCompleter) complete(ctx context.Context, prompt string) (string, error) { |
|
016a29f…
|
lmata
|
82 |
return d.provider.Summarize(ctx, prompt) |
|
016a29f…
|
lmata
|
83 |
} |
|
016a29f…
|
lmata
|
84 |
|
|
016a29f…
|
lmata
|
85 |
type gatewayCompleter struct { |
|
016a29f…
|
lmata
|
86 |
apiURL string |
|
016a29f…
|
lmata
|
87 |
token string |
|
016a29f…
|
lmata
|
88 |
backend string |
|
016a29f…
|
lmata
|
89 |
http *http.Client |
|
016a29f…
|
lmata
|
90 |
} |
|
016a29f…
|
lmata
|
91 |
|
|
016a29f…
|
lmata
|
92 |
func (g *gatewayCompleter) complete(ctx context.Context, prompt string) (string, error) { |
|
016a29f…
|
lmata
|
93 |
body, _ := json.Marshal(map[string]string{"backend": g.backend, "prompt": prompt}) |
|
016a29f…
|
lmata
|
94 |
req, err := http.NewRequestWithContext(ctx, "POST", g.apiURL+"/v1/llm/complete", bytes.NewReader(body)) |
|
016a29f…
|
lmata
|
95 |
if err != nil { |
|
016a29f…
|
lmata
|
96 |
return "", err |
|
016a29f…
|
lmata
|
97 |
} |
|
016a29f…
|
lmata
|
98 |
req.Header.Set("Content-Type", "application/json") |
|
016a29f…
|
lmata
|
99 |
req.Header.Set("Authorization", "Bearer "+g.token) |
|
016a29f…
|
lmata
|
100 |
|
|
016a29f…
|
lmata
|
101 |
resp, err := g.http.Do(req) |
|
016a29f…
|
lmata
|
102 |
if err != nil { |
|
016a29f…
|
lmata
|
103 |
return "", fmt.Errorf("gateway request: %w", err) |
|
016a29f…
|
lmata
|
104 |
} |
|
016a29f…
|
lmata
|
105 |
defer resp.Body.Close() |
|
016a29f…
|
lmata
|
106 |
|
|
016a29f…
|
lmata
|
107 |
data, _ := io.ReadAll(resp.Body) |
|
016a29f…
|
lmata
|
108 |
if resp.StatusCode != http.StatusOK { |
|
016a29f…
|
lmata
|
109 |
return "", fmt.Errorf("gateway error %d: %s", resp.StatusCode, string(data)) |
|
016a29f…
|
lmata
|
110 |
} |
|
016a29f…
|
lmata
|
111 |
|
|
016a29f…
|
lmata
|
112 |
var result struct { |
|
016a29f…
|
lmata
|
113 |
Text string `json:"text"` |
|
016a29f…
|
lmata
|
114 |
} |
|
016a29f…
|
lmata
|
115 |
if err := json.Unmarshal(data, &result); err != nil { |
|
016a29f…
|
lmata
|
116 |
return "", fmt.Errorf("gateway parse: %w", err) |
|
016a29f…
|
lmata
|
117 |
} |
|
016a29f…
|
lmata
|
118 |
return result.Text, nil |
|
016a29f…
|
lmata
|
119 |
} |
|
016a29f…
|
lmata
|
120 |
|
|
016a29f…
|
lmata
|
121 |
type agent struct { |
|
016a29f…
|
lmata
|
122 |
cfg Config |
|
016a29f…
|
lmata
|
123 |
llm completer |
|
016a29f…
|
lmata
|
124 |
log *slog.Logger |
|
016a29f…
|
lmata
|
125 |
irc *girc.Client |
|
016a29f…
|
lmata
|
126 |
mu sync.Mutex |
|
016a29f…
|
lmata
|
127 |
history map[string][]historyEntry |
|
016a29f…
|
lmata
|
128 |
} |
|
016a29f…
|
lmata
|
129 |
|
|
016a29f…
|
lmata
|
130 |
// Run starts the IRC agent and blocks until the context is canceled or the IRC |
|
016a29f…
|
lmata
|
131 |
// connection fails. |
|
016a29f…
|
lmata
|
132 |
func Run(ctx context.Context, cfg Config) error { |
|
016a29f…
|
lmata
|
133 |
cfg = withDefaults(cfg) |
|
016a29f…
|
lmata
|
134 |
if err := validateConfig(cfg); err != nil { |
|
016a29f…
|
lmata
|
135 |
return err |
|
016a29f…
|
lmata
|
136 |
} |
|
016a29f…
|
lmata
|
137 |
|
|
016a29f…
|
lmata
|
138 |
llmClient, err := buildCompleter(cfg) |
|
016a29f…
|
lmata
|
139 |
if err != nil { |
|
016a29f…
|
lmata
|
140 |
return err |
|
016a29f…
|
lmata
|
141 |
} |
|
016a29f…
|
lmata
|
142 |
|
|
016a29f…
|
lmata
|
143 |
a := &agent{ |
|
016a29f…
|
lmata
|
144 |
cfg: cfg, |
|
016a29f…
|
lmata
|
145 |
llm: llmClient, |
|
016a29f…
|
lmata
|
146 |
log: cfg.Logger, |
|
016a29f…
|
lmata
|
147 |
history: make(map[string][]historyEntry), |
|
016a29f…
|
lmata
|
148 |
} |
|
016a29f…
|
lmata
|
149 |
return a.run(ctx) |
|
016a29f…
|
lmata
|
150 |
} |
|
016a29f…
|
lmata
|
151 |
|
|
016a29f…
|
lmata
|
152 |
// SplitCSV trims and splits comma-separated channel strings. |
|
016a29f…
|
lmata
|
153 |
func SplitCSV(s string) []string { |
|
016a29f…
|
lmata
|
154 |
var out []string |
|
016a29f…
|
lmata
|
155 |
for _, part := range strings.Split(s, ",") { |
|
016a29f…
|
lmata
|
156 |
if part = strings.TrimSpace(part); part != "" { |
|
016a29f…
|
lmata
|
157 |
out = append(out, part) |
|
016a29f…
|
lmata
|
158 |
} |
|
016a29f…
|
lmata
|
159 |
} |
|
016a29f…
|
lmata
|
160 |
return out |
|
016a29f…
|
lmata
|
161 |
} |
|
016a29f…
|
lmata
|
162 |
|
|
016a29f…
|
lmata
|
163 |
func withDefaults(cfg Config) Config { |
|
016a29f…
|
lmata
|
164 |
if cfg.Logger == nil { |
|
016a29f…
|
lmata
|
165 |
cfg.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) |
|
016a29f…
|
lmata
|
166 |
} |
|
016a29f…
|
lmata
|
167 |
if cfg.HistoryLen <= 0 { |
|
016a29f…
|
lmata
|
168 |
cfg.HistoryLen = defaultHistoryLen |
|
016a29f…
|
lmata
|
169 |
} |
|
016a29f…
|
lmata
|
170 |
if cfg.TypingDelay <= 0 { |
|
016a29f…
|
lmata
|
171 |
cfg.TypingDelay = defaultTypingDelay |
|
016a29f…
|
lmata
|
172 |
} |
|
016a29f…
|
lmata
|
173 |
if cfg.ErrorJoiner == "" { |
|
016a29f…
|
lmata
|
174 |
cfg.ErrorJoiner = defaultErrorJoiner |
|
016a29f…
|
lmata
|
175 |
} |
|
016a29f…
|
lmata
|
176 |
if len(cfg.ActivityPrefixes) == 0 { |
|
016a29f…
|
lmata
|
177 |
cfg.ActivityPrefixes = append([]string(nil), defaultActivityPrefixes...) |
|
016a29f…
|
lmata
|
178 |
} |
|
016a29f…
|
lmata
|
179 |
if len(cfg.Channels) == 0 { |
|
016a29f…
|
lmata
|
180 |
cfg.Channels = []string{"#general"} |
|
016a29f…
|
lmata
|
181 |
} |
|
016a29f…
|
lmata
|
182 |
return cfg |
|
016a29f…
|
lmata
|
183 |
} |
|
016a29f…
|
lmata
|
184 |
|
|
016a29f…
|
lmata
|
185 |
func validateConfig(cfg Config) error { |
|
016a29f…
|
lmata
|
186 |
switch { |
|
016a29f…
|
lmata
|
187 |
case cfg.IRCAddr == "": |
|
016a29f…
|
lmata
|
188 |
return fmt.Errorf("irc address is required") |
|
016a29f…
|
lmata
|
189 |
case cfg.Nick == "": |
|
016a29f…
|
lmata
|
190 |
return fmt.Errorf("nick is required") |
|
016a29f…
|
lmata
|
191 |
case cfg.Pass == "": |
|
016a29f…
|
lmata
|
192 |
return fmt.Errorf("pass is required") |
|
016a29f…
|
lmata
|
193 |
case cfg.SystemPrompt == "": |
|
016a29f…
|
lmata
|
194 |
return fmt.Errorf("system prompt is required") |
|
016a29f…
|
lmata
|
195 |
} |
|
016a29f…
|
lmata
|
196 |
return nil |
|
016a29f…
|
lmata
|
197 |
} |
|
016a29f…
|
lmata
|
198 |
|
|
016a29f…
|
lmata
|
199 |
func buildCompleter(cfg Config) (completer, error) { |
|
016a29f…
|
lmata
|
200 |
gatewayConfigured := cfg.Gateway != nil && cfg.Gateway.Token != "" |
|
016a29f…
|
lmata
|
201 |
directConfigured := cfg.Direct != nil && cfg.Direct.APIKey != "" |
|
016a29f…
|
lmata
|
202 |
|
|
016a29f…
|
lmata
|
203 |
if gatewayConfigured && !directConfigured { |
|
016a29f…
|
lmata
|
204 |
if cfg.Gateway.APIURL == "" { |
|
016a29f…
|
lmata
|
205 |
return nil, fmt.Errorf("gateway api url is required") |
|
016a29f…
|
lmata
|
206 |
} |
|
016a29f…
|
lmata
|
207 |
if cfg.Gateway.Backend == "" { |
|
016a29f…
|
lmata
|
208 |
return nil, fmt.Errorf("gateway backend is required") |
|
016a29f…
|
lmata
|
209 |
} |
|
016a29f…
|
lmata
|
210 |
httpClient := cfg.Gateway.HTTPClient |
|
016a29f…
|
lmata
|
211 |
if httpClient == nil { |
|
016a29f…
|
lmata
|
212 |
httpClient = &http.Client{Timeout: defaultGatewayTimout} |
|
016a29f…
|
lmata
|
213 |
} |
|
016a29f…
|
lmata
|
214 |
cfg.Logger.Info("mode: gateway", "api-url", cfg.Gateway.APIURL, "backend", cfg.Gateway.Backend) |
|
016a29f…
|
lmata
|
215 |
return &gatewayCompleter{ |
|
016a29f…
|
lmata
|
216 |
apiURL: cfg.Gateway.APIURL, |
|
016a29f…
|
lmata
|
217 |
token: cfg.Gateway.Token, |
|
016a29f…
|
lmata
|
218 |
backend: cfg.Gateway.Backend, |
|
016a29f…
|
lmata
|
219 |
http: httpClient, |
|
016a29f…
|
lmata
|
220 |
}, nil |
|
016a29f…
|
lmata
|
221 |
} |
|
016a29f…
|
lmata
|
222 |
|
|
016a29f…
|
lmata
|
223 |
if directConfigured { |
|
016a29f…
|
lmata
|
224 |
if cfg.Direct.Backend == "" { |
|
016a29f…
|
lmata
|
225 |
return nil, fmt.Errorf("direct backend is required") |
|
016a29f…
|
lmata
|
226 |
} |
|
016a29f…
|
lmata
|
227 |
cfg.Logger.Info("mode: direct", "backend", cfg.Direct.Backend, "model", cfg.Direct.Model) |
|
016a29f…
|
lmata
|
228 |
provider, err := llm.New(llm.BackendConfig{ |
|
016a29f…
|
lmata
|
229 |
Backend: cfg.Direct.Backend, |
|
016a29f…
|
lmata
|
230 |
APIKey: cfg.Direct.APIKey, |
|
016a29f…
|
lmata
|
231 |
Model: cfg.Direct.Model, |
|
016a29f…
|
lmata
|
232 |
}) |
|
016a29f…
|
lmata
|
233 |
if err != nil { |
|
016a29f…
|
lmata
|
234 |
return nil, fmt.Errorf("build provider: %w", err) |
|
016a29f…
|
lmata
|
235 |
} |
|
016a29f…
|
lmata
|
236 |
return &directCompleter{provider: provider}, nil |
|
016a29f…
|
lmata
|
237 |
} |
|
016a29f…
|
lmata
|
238 |
|
|
016a29f…
|
lmata
|
239 |
return nil, fmt.Errorf("set gateway token or direct api key") |
|
016a29f…
|
lmata
|
240 |
} |
|
016a29f…
|
lmata
|
241 |
|
|
016a29f…
|
lmata
|
242 |
func (a *agent) run(ctx context.Context) error { |
|
016a29f…
|
lmata
|
243 |
host, port, err := splitHostPort(a.cfg.IRCAddr) |
|
016a29f…
|
lmata
|
244 |
if err != nil { |
|
016a29f…
|
lmata
|
245 |
return err |
|
016a29f…
|
lmata
|
246 |
} |
|
016a29f…
|
lmata
|
247 |
|
|
016a29f…
|
lmata
|
248 |
client := girc.New(girc.Config{ |
|
016a29f…
|
lmata
|
249 |
Server: host, |
|
016a29f…
|
lmata
|
250 |
Port: port, |
|
016a29f…
|
lmata
|
251 |
Nick: a.cfg.Nick, |
|
016a29f…
|
lmata
|
252 |
User: a.cfg.Nick, |
|
016a29f…
|
lmata
|
253 |
Name: a.cfg.Nick + " (AI agent)", |
|
016a29f…
|
lmata
|
254 |
SASL: &girc.SASLPlain{User: a.cfg.Nick, Pass: a.cfg.Pass}, |
|
016a29f…
|
lmata
|
255 |
}) |
|
016a29f…
|
lmata
|
256 |
|
|
016a29f…
|
lmata
|
257 |
client.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
|
016a29f…
|
lmata
|
258 |
a.log.Info("connected", "server", a.cfg.IRCAddr) |
|
016a29f…
|
lmata
|
259 |
for _, ch := range a.cfg.Channels { |
|
016a29f…
|
lmata
|
260 |
cl.Cmd.Join(ch) |
|
016a29f…
|
lmata
|
261 |
} |
|
016a29f…
|
lmata
|
262 |
}) |
|
016a29f…
|
lmata
|
263 |
|
|
016a29f…
|
lmata
|
264 |
client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { |
|
016a29f…
|
lmata
|
265 |
if len(e.Params) < 1 || e.Source == nil { |
|
016a29f…
|
lmata
|
266 |
return |
|
016a29f…
|
lmata
|
267 |
} |
|
016a29f…
|
lmata
|
268 |
|
|
016a29f…
|
lmata
|
269 |
target := e.Params[0] |
|
016a29f…
|
lmata
|
270 |
senderNick := e.Source.Name |
|
016a29f…
|
lmata
|
271 |
text := strings.TrimSpace(e.Last()) |
|
016a29f…
|
lmata
|
272 |
if senderNick == a.cfg.Nick { |
|
016a29f…
|
lmata
|
273 |
return |
|
016a29f…
|
lmata
|
274 |
} |
|
016a29f…
|
lmata
|
275 |
|
|
c3c693d…
|
noreply
|
276 |
// RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. |
|
c3c693d…
|
noreply
|
277 |
if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
|
c3c693d…
|
noreply
|
278 |
if idx := strings.Index(senderNick, sep); idx != -1 { |
|
c3c693d…
|
noreply
|
279 |
senderNick = senderNick[:idx] |
|
c3c693d…
|
noreply
|
280 |
} |
|
c3c693d…
|
noreply
|
281 |
} |
|
c3c693d…
|
noreply
|
282 |
// Fallback: parse legacy [nick] prefix from bridge bot. |
|
016a29f…
|
lmata
|
283 |
if strings.HasPrefix(text, "[") { |
|
016a29f…
|
lmata
|
284 |
if end := strings.Index(text, "] "); end != -1 { |
|
016a29f…
|
lmata
|
285 |
senderNick = text[1:end] |
|
016a29f…
|
lmata
|
286 |
text = text[end+2:] |
|
016a29f…
|
lmata
|
287 |
} |
|
016a29f…
|
lmata
|
288 |
} |
|
016a29f…
|
lmata
|
289 |
|
|
016a29f…
|
lmata
|
290 |
isDM := !strings.HasPrefix(target, "#") |
|
016a29f…
|
lmata
|
291 |
isMentioned := MentionsNick(text, a.cfg.Nick) |
|
016a29f…
|
lmata
|
292 |
isActivityPost := HasAnyPrefix(senderNick, a.cfg.ActivityPrefixes) |
|
016a29f…
|
lmata
|
293 |
|
|
016a29f…
|
lmata
|
294 |
convKey := target |
|
016a29f…
|
lmata
|
295 |
if isDM { |
|
016a29f…
|
lmata
|
296 |
convKey = senderNick |
|
016a29f…
|
lmata
|
297 |
} |
|
016a29f…
|
lmata
|
298 |
a.appendHistory(convKey, "user", senderNick, text) |
|
016a29f…
|
lmata
|
299 |
|
|
016a29f…
|
lmata
|
300 |
if isActivityPost { |
|
016a29f…
|
lmata
|
301 |
return |
|
016a29f…
|
lmata
|
302 |
} |
|
016a29f…
|
lmata
|
303 |
if !isDM && !isMentioned { |
|
016a29f…
|
lmata
|
304 |
return |
|
016a29f…
|
lmata
|
305 |
} |
|
016a29f…
|
lmata
|
306 |
|
|
016a29f…
|
lmata
|
307 |
cleaned := TrimAddressedText(text, a.cfg.Nick) |
|
016a29f…
|
lmata
|
308 |
|
|
016a29f…
|
lmata
|
309 |
a.mu.Lock() |
|
016a29f…
|
lmata
|
310 |
history := a.history[convKey] |
|
016a29f…
|
lmata
|
311 |
if len(history) > 0 { |
|
016a29f…
|
lmata
|
312 |
history[len(history)-1].content = cleaned |
|
016a29f…
|
lmata
|
313 |
a.history[convKey] = history |
|
016a29f…
|
lmata
|
314 |
} |
|
016a29f…
|
lmata
|
315 |
a.mu.Unlock() |
|
016a29f…
|
lmata
|
316 |
|
|
016a29f…
|
lmata
|
317 |
replyTo := target |
|
016a29f…
|
lmata
|
318 |
if isDM { |
|
016a29f…
|
lmata
|
319 |
replyTo = senderNick |
|
016a29f…
|
lmata
|
320 |
} |
|
016a29f…
|
lmata
|
321 |
go a.respond(ctx, cl, convKey, replyTo, senderNick, isDM) |
|
016a29f…
|
lmata
|
322 |
}) |
|
016a29f…
|
lmata
|
323 |
|
|
016a29f…
|
lmata
|
324 |
a.irc = client |
|
016a29f…
|
lmata
|
325 |
|
|
016a29f…
|
lmata
|
326 |
errCh := make(chan error, 1) |
|
016a29f…
|
lmata
|
327 |
go func() { |
|
016a29f…
|
lmata
|
328 |
if err := client.Connect(); err != nil && ctx.Err() == nil { |
|
016a29f…
|
lmata
|
329 |
errCh <- err |
|
016a29f…
|
lmata
|
330 |
} |
|
016a29f…
|
lmata
|
331 |
}() |
|
016a29f…
|
lmata
|
332 |
|
|
016a29f…
|
lmata
|
333 |
select { |
|
016a29f…
|
lmata
|
334 |
case <-ctx.Done(): |
|
016a29f…
|
lmata
|
335 |
client.Close() |
|
016a29f…
|
lmata
|
336 |
return nil |
|
016a29f…
|
lmata
|
337 |
case err := <-errCh: |
|
016a29f…
|
lmata
|
338 |
return fmt.Errorf("irc: %w", err) |
|
016a29f…
|
lmata
|
339 |
} |
|
016a29f…
|
lmata
|
340 |
} |
|
016a29f…
|
lmata
|
341 |
|
|
016a29f…
|
lmata
|
342 |
func (a *agent) respond(ctx context.Context, cl *girc.Client, convKey, replyTo, senderNick string, isDM bool) { |
|
016a29f…
|
lmata
|
343 |
prompt := a.buildPrompt(convKey) |
|
016a29f…
|
lmata
|
344 |
time.Sleep(a.cfg.TypingDelay) |
|
016a29f…
|
lmata
|
345 |
|
|
016a29f…
|
lmata
|
346 |
reply, err := a.llm.complete(ctx, prompt) |
|
016a29f…
|
lmata
|
347 |
if err != nil { |
|
016a29f…
|
lmata
|
348 |
a.log.Error("llm error", "err", err) |
|
016a29f…
|
lmata
|
349 |
cl.Cmd.Message(replyTo, senderNick+": sorry, something went wrong"+a.cfg.ErrorJoiner+err.Error()) |
|
016a29f…
|
lmata
|
350 |
return |
|
016a29f…
|
lmata
|
351 |
} |
|
016a29f…
|
lmata
|
352 |
|
|
016a29f…
|
lmata
|
353 |
reply = strings.TrimSpace(reply) |
|
016a29f…
|
lmata
|
354 |
a.appendHistory(convKey, "assistant", a.cfg.Nick, reply) |
|
016a29f…
|
lmata
|
355 |
|
|
016a29f…
|
lmata
|
356 |
prefix := "" |
|
016a29f…
|
lmata
|
357 |
if !isDM && senderNick != "" { |
|
016a29f…
|
lmata
|
358 |
prefix = senderNick + ": " |
|
016a29f…
|
lmata
|
359 |
} |
|
016a29f…
|
lmata
|
360 |
for i, line := range strings.Split(reply, "\n") { |
|
016a29f…
|
lmata
|
361 |
line = strings.TrimSpace(line) |
|
016a29f…
|
lmata
|
362 |
if line == "" { |
|
016a29f…
|
lmata
|
363 |
continue |
|
016a29f…
|
lmata
|
364 |
} |
|
016a29f…
|
lmata
|
365 |
if i == 0 { |
|
016a29f…
|
lmata
|
366 |
line = prefix + line |
|
016a29f…
|
lmata
|
367 |
} |
|
016a29f…
|
lmata
|
368 |
cl.Cmd.Message(replyTo, line) |
|
016a29f…
|
lmata
|
369 |
} |
|
016a29f…
|
lmata
|
370 |
} |
|
016a29f…
|
lmata
|
371 |
|
|
016a29f…
|
lmata
|
372 |
func (a *agent) buildPrompt(convKey string) string { |
|
016a29f…
|
lmata
|
373 |
a.mu.Lock() |
|
016a29f…
|
lmata
|
374 |
history := append([]historyEntry(nil), a.history[convKey]...) |
|
016a29f…
|
lmata
|
375 |
a.mu.Unlock() |
|
016a29f…
|
lmata
|
376 |
|
|
016a29f…
|
lmata
|
377 |
var sb strings.Builder |
|
016a29f…
|
lmata
|
378 |
sb.WriteString(a.cfg.SystemPrompt) |
|
016a29f…
|
lmata
|
379 |
sb.WriteString("\n\nConversation history:\n") |
|
016a29f…
|
lmata
|
380 |
for _, entry := range history { |
|
016a29f…
|
lmata
|
381 |
role := "User" |
|
016a29f…
|
lmata
|
382 |
if entry.role == "assistant" { |
|
016a29f…
|
lmata
|
383 |
role = "Assistant" |
|
016a29f…
|
lmata
|
384 |
} |
|
016a29f…
|
lmata
|
385 |
fmt.Fprintf(&sb, "[%s] %s: %s\n", role, entry.nick, entry.content) |
|
016a29f…
|
lmata
|
386 |
} |
|
016a29f…
|
lmata
|
387 |
sb.WriteString("\nRespond to the last user message. Be concise.") |
|
016a29f…
|
lmata
|
388 |
return sb.String() |
|
016a29f…
|
lmata
|
389 |
} |
|
016a29f…
|
lmata
|
390 |
|
|
016a29f…
|
lmata
|
391 |
func (a *agent) appendHistory(convKey, role, nick, content string) { |
|
016a29f…
|
lmata
|
392 |
a.mu.Lock() |
|
016a29f…
|
lmata
|
393 |
defer a.mu.Unlock() |
|
016a29f…
|
lmata
|
394 |
|
|
016a29f…
|
lmata
|
395 |
history := a.history[convKey] |
|
016a29f…
|
lmata
|
396 |
history = append(history, historyEntry{role: role, nick: nick, content: content}) |
|
016a29f…
|
lmata
|
397 |
if len(history) > a.cfg.HistoryLen { |
|
016a29f…
|
lmata
|
398 |
history = history[len(history)-a.cfg.HistoryLen:] |
|
016a29f…
|
lmata
|
399 |
} |
|
016a29f…
|
lmata
|
400 |
a.history[convKey] = history |
|
016a29f…
|
lmata
|
401 |
} |
|
016a29f…
|
lmata
|
402 |
|
|
016a29f…
|
lmata
|
403 |
// MentionsNick reports whether text contains a standalone mention of nick. |
|
016a29f…
|
lmata
|
404 |
func MentionsNick(text, nick string) bool { |
|
016a29f…
|
lmata
|
405 |
lower := strings.ToLower(text) |
|
016a29f…
|
lmata
|
406 |
needle := strings.ToLower(nick) |
|
016a29f…
|
lmata
|
407 |
start := 0 |
|
016a29f…
|
lmata
|
408 |
|
|
016a29f…
|
lmata
|
409 |
for { |
|
016a29f…
|
lmata
|
410 |
idx := strings.Index(lower[start:], needle) |
|
016a29f…
|
lmata
|
411 |
if idx < 0 { |
|
016a29f…
|
lmata
|
412 |
return false |
|
016a29f…
|
lmata
|
413 |
} |
|
016a29f…
|
lmata
|
414 |
idx += start |
|
016a29f…
|
lmata
|
415 |
|
|
016a29f…
|
lmata
|
416 |
before := idx == 0 || !isMentionAdjacent(lower[idx-1]) |
|
016a29f…
|
lmata
|
417 |
after := idx+len(needle) >= len(lower) || !isMentionAdjacent(lower[idx+len(needle)]) |
|
016a29f…
|
lmata
|
418 |
if before && after { |
|
016a29f…
|
lmata
|
419 |
return true |
|
016a29f…
|
lmata
|
420 |
} |
|
016a29f…
|
lmata
|
421 |
|
|
016a29f…
|
lmata
|
422 |
start = idx + 1 |
|
016a29f…
|
lmata
|
423 |
} |
|
cefe27d…
|
lmata
|
424 |
} |
|
cefe27d…
|
lmata
|
425 |
|
|
cefe27d…
|
lmata
|
426 |
// MatchesGroupMention checks if text contains a group mention that applies |
|
cefe27d…
|
lmata
|
427 |
// to an agent with the given nick and type. Supported patterns: |
|
cefe27d…
|
lmata
|
428 |
// |
|
cefe27d…
|
lmata
|
429 |
// - @all — matches every agent |
|
cefe27d…
|
lmata
|
430 |
// - @worker, @observer, @orchestrator, @operator — matches by agent type |
|
cefe27d…
|
lmata
|
431 |
// - @prefix-* — matches agents whose nick starts with prefix- (e.g. @claude-* matches claude-kohakku-abc) |
|
cefe27d…
|
lmata
|
432 |
func MatchesGroupMention(text, nick, agentType string) bool { |
|
cefe27d…
|
lmata
|
433 |
lower := strings.ToLower(text) |
|
cefe27d…
|
lmata
|
434 |
|
|
cefe27d…
|
lmata
|
435 |
// @all |
|
cefe27d…
|
lmata
|
436 |
if containsWord(lower, "@all") { |
|
cefe27d…
|
lmata
|
437 |
return true |
|
cefe27d…
|
lmata
|
438 |
} |
|
cefe27d…
|
lmata
|
439 |
|
|
cefe27d…
|
lmata
|
440 |
// @role — e.g. @worker, @observer |
|
cefe27d…
|
lmata
|
441 |
if agentType != "" && containsWord(lower, "@"+strings.ToLower(agentType)) { |
|
cefe27d…
|
lmata
|
442 |
return true |
|
cefe27d…
|
lmata
|
443 |
} |
|
cefe27d…
|
lmata
|
444 |
|
|
cefe27d…
|
lmata
|
445 |
// @prefix-* patterns — find all @word-* tokens in the text. |
|
cefe27d…
|
lmata
|
446 |
for i := 0; i < len(lower); i++ { |
|
cefe27d…
|
lmata
|
447 |
if lower[i] != '@' { |
|
cefe27d…
|
lmata
|
448 |
continue |
|
cefe27d…
|
lmata
|
449 |
} |
|
cefe27d…
|
lmata
|
450 |
// Extract the token after @. |
|
cefe27d…
|
lmata
|
451 |
j := i + 1 |
|
cefe27d…
|
lmata
|
452 |
for j < len(lower) && (isAlNum(lower[j]) || lower[j] == '*') { |
|
cefe27d…
|
lmata
|
453 |
j++ |
|
cefe27d…
|
lmata
|
454 |
} |
|
cefe27d…
|
lmata
|
455 |
token := lower[i+1 : j] |
|
cefe27d…
|
lmata
|
456 |
if !strings.HasSuffix(token, "*") || len(token) < 2 { |
|
cefe27d…
|
lmata
|
457 |
continue |
|
cefe27d…
|
lmata
|
458 |
} |
|
cefe27d…
|
lmata
|
459 |
prefix := token[:len(token)-1] // remove the * |
|
cefe27d…
|
lmata
|
460 |
if strings.HasPrefix(strings.ToLower(nick), prefix) { |
|
cefe27d…
|
lmata
|
461 |
return true |
|
cefe27d…
|
lmata
|
462 |
} |
|
cefe27d…
|
lmata
|
463 |
} |
|
cefe27d…
|
lmata
|
464 |
|
|
cefe27d…
|
lmata
|
465 |
return false |
|
cefe27d…
|
lmata
|
466 |
} |
|
cefe27d…
|
lmata
|
467 |
|
|
cefe27d…
|
lmata
|
468 |
func containsWord(text, word string) bool { |
|
cefe27d…
|
lmata
|
469 |
idx := strings.Index(text, word) |
|
cefe27d…
|
lmata
|
470 |
if idx < 0 { |
|
cefe27d…
|
lmata
|
471 |
return false |
|
cefe27d…
|
lmata
|
472 |
} |
|
cefe27d…
|
lmata
|
473 |
end := idx + len(word) |
|
cefe27d…
|
lmata
|
474 |
before := idx == 0 || !isAlNum(text[idx-1]) |
|
cefe27d…
|
lmata
|
475 |
after := end >= len(text) || !isAlNum(text[end]) |
|
cefe27d…
|
lmata
|
476 |
return before && after |
|
016a29f…
|
lmata
|
477 |
} |
|
016a29f…
|
lmata
|
478 |
|
|
016a29f…
|
lmata
|
479 |
// TrimAddressedText removes an initial nick address from text when present. |
|
016a29f…
|
lmata
|
480 |
func TrimAddressedText(text, nick string) string { |
|
016a29f…
|
lmata
|
481 |
cleaned := text |
|
016a29f…
|
lmata
|
482 |
lower := strings.ToLower(text) |
|
016a29f…
|
lmata
|
483 |
if idx := strings.Index(lower, strings.ToLower(nick)); idx != -1 { |
|
016a29f…
|
lmata
|
484 |
after := strings.TrimSpace(text[idx+len(nick):]) |
|
016a29f…
|
lmata
|
485 |
after = strings.TrimLeft(after, ":, ") |
|
016a29f…
|
lmata
|
486 |
if after != "" { |
|
016a29f…
|
lmata
|
487 |
cleaned = after |
|
016a29f…
|
lmata
|
488 |
} |
|
016a29f…
|
lmata
|
489 |
} |
|
016a29f…
|
lmata
|
490 |
return cleaned |
|
016a29f…
|
lmata
|
491 |
} |
|
016a29f…
|
lmata
|
492 |
|
|
016a29f…
|
lmata
|
493 |
func isAlNum(c byte) bool { |
|
016a29f…
|
lmata
|
494 |
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' |
|
016a29f…
|
lmata
|
495 |
} |
|
016a29f…
|
lmata
|
496 |
|
|
016a29f…
|
lmata
|
497 |
func isMentionAdjacent(c byte) bool { |
|
016a29f…
|
lmata
|
498 |
return isAlNum(c) || c == '.' || c == '/' || c == '\\' |
|
016a29f…
|
lmata
|
499 |
} |
|
016a29f…
|
lmata
|
500 |
|
|
016a29f…
|
lmata
|
501 |
// HasAnyPrefix reports whether s starts with any prefix in prefixes. |
|
016a29f…
|
lmata
|
502 |
func HasAnyPrefix(s string, prefixes []string) bool { |
|
016a29f…
|
lmata
|
503 |
for _, prefix := range prefixes { |
|
016a29f…
|
lmata
|
504 |
if strings.HasPrefix(s, prefix) { |
|
016a29f…
|
lmata
|
505 |
return true |
|
016a29f…
|
lmata
|
506 |
} |
|
016a29f…
|
lmata
|
507 |
} |
|
016a29f…
|
lmata
|
508 |
return false |
|
016a29f…
|
lmata
|
509 |
} |
|
016a29f…
|
lmata
|
510 |
|
|
016a29f…
|
lmata
|
511 |
func splitHostPort(addr string) (string, int, error) { |
|
016a29f…
|
lmata
|
512 |
host, portStr, err := net.SplitHostPort(addr) |
|
016a29f…
|
lmata
|
513 |
if err != nil { |
|
016a29f…
|
lmata
|
514 |
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
|
016a29f…
|
lmata
|
515 |
} |
|
016a29f…
|
lmata
|
516 |
port, err := strconv.Atoi(portStr) |
|
016a29f…
|
lmata
|
517 |
if err != nil { |
|
016a29f…
|
lmata
|
518 |
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) |
|
016a29f…
|
lmata
|
519 |
} |
|
016a29f…
|
lmata
|
520 |
return host, port, nil |
|
016a29f…
|
lmata
|
521 |
} |