|
1
|
// Package oracle implements the oracle bot — on-demand channel summarization. |
|
2
|
// |
|
3
|
// Agents and humans send oracle a DM: |
|
4
|
// |
|
5
|
// PRIVMSG oracle :summarize #fleet [last=50] [format=toon|json] |
|
6
|
// |
|
7
|
// oracle fetches recent messages from the channel history store, calls the |
|
8
|
// configured LLM provider for summarization, and replies in PM via NOTICE. |
|
9
|
// Output format is either TOON (default, token-efficient) or JSON. |
|
10
|
// |
|
11
|
// oracle never sends to channels — only PM replies. |
|
12
|
package oracle |
|
13
|
|
|
14
|
import ( |
|
15
|
"context" |
|
16
|
"fmt" |
|
17
|
"log/slog" |
|
18
|
"net" |
|
19
|
"strconv" |
|
20
|
"strings" |
|
21
|
"sync" |
|
22
|
"time" |
|
23
|
|
|
24
|
"github.com/lrstanley/girc" |
|
25
|
|
|
26
|
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse" |
|
27
|
"github.com/conflicthq/scuttlebot/pkg/chathistory" |
|
28
|
"github.com/conflicthq/scuttlebot/pkg/toon" |
|
29
|
) |
|
30
|
|
|
31
|
const ( |
|
32
|
botNick = "oracle" |
|
33
|
defaultLimit = 50 |
|
34
|
maxLimit = 200 |
|
35
|
rateLimitWait = 30 * time.Second |
|
36
|
) |
|
37
|
|
|
38
|
// Format is the output format for oracle responses. |
|
39
|
type Format string |
|
40
|
|
|
41
|
const ( |
|
42
|
FormatTOON Format = "toon" |
|
43
|
FormatJSON Format = "json" |
|
44
|
) |
|
45
|
|
|
46
|
// HistoryEntry is a single message from channel history. |
|
47
|
type HistoryEntry struct { |
|
48
|
Nick string |
|
49
|
MessageType string // empty for raw/human messages |
|
50
|
Raw string |
|
51
|
} |
|
52
|
|
|
53
|
// HistoryFetcher retrieves recent messages from a channel. |
|
54
|
type HistoryFetcher interface { |
|
55
|
Query(channel string, limit int) ([]HistoryEntry, error) |
|
56
|
} |
|
57
|
|
|
58
|
// LLMProvider calls a language model for summarization. |
|
59
|
// Implementations are pluggable — oracle does not hardcode any provider. |
|
60
|
type LLMProvider interface { |
|
61
|
Summarize(ctx context.Context, prompt string) (string, error) |
|
62
|
} |
|
63
|
|
|
64
|
// SummarizeRequest is a parsed oracle command. |
|
65
|
type SummarizeRequest struct { |
|
66
|
Channel string |
|
67
|
Limit int |
|
68
|
Format Format |
|
69
|
} |
|
70
|
|
|
71
|
// ParseCommand parses "summarize #channel [last=N] [format=toon|json]". |
|
72
|
// Returns an error for malformed input; ignores unrecognised tokens. |
|
73
|
func ParseCommand(text string) (*SummarizeRequest, error) { |
|
74
|
text = strings.TrimSpace(text) |
|
75
|
parts := strings.Fields(text) |
|
76
|
if len(parts) < 2 { |
|
77
|
return nil, fmt.Errorf("usage: summarize <#channel> [last=N] [format=toon|json]") |
|
78
|
} |
|
79
|
if !strings.EqualFold(parts[0], "summarize") { |
|
80
|
return nil, fmt.Errorf("unknown command %q", parts[0]) |
|
81
|
} |
|
82
|
|
|
83
|
req := &SummarizeRequest{ |
|
84
|
Channel: parts[1], |
|
85
|
Limit: defaultLimit, |
|
86
|
Format: FormatTOON, |
|
87
|
} |
|
88
|
|
|
89
|
if !strings.HasPrefix(req.Channel, "#") { |
|
90
|
return nil, fmt.Errorf("channel must start with # (got %q)", req.Channel) |
|
91
|
} |
|
92
|
|
|
93
|
for _, token := range parts[2:] { |
|
94
|
kv := strings.SplitN(token, "=", 2) |
|
95
|
if len(kv) != 2 { |
|
96
|
continue |
|
97
|
} |
|
98
|
switch strings.ToLower(kv[0]) { |
|
99
|
case "last": |
|
100
|
n, err := strconv.Atoi(kv[1]) |
|
101
|
if err != nil || n <= 0 { |
|
102
|
return nil, fmt.Errorf("last= must be a positive integer") |
|
103
|
} |
|
104
|
if n > maxLimit { |
|
105
|
n = maxLimit |
|
106
|
} |
|
107
|
req.Limit = n |
|
108
|
case "format": |
|
109
|
switch Format(strings.ToLower(kv[1])) { |
|
110
|
case FormatTOON: |
|
111
|
req.Format = FormatTOON |
|
112
|
case FormatJSON: |
|
113
|
req.Format = FormatJSON |
|
114
|
default: |
|
115
|
return nil, fmt.Errorf("format must be toon or json (got %q)", kv[1]) |
|
116
|
} |
|
117
|
} |
|
118
|
} |
|
119
|
return req, nil |
|
120
|
} |
|
121
|
|
|
122
|
// Bot is the oracle bot. |
|
123
|
type Bot struct { |
|
124
|
ircAddr string |
|
125
|
password string |
|
126
|
channels []string |
|
127
|
history HistoryFetcher |
|
128
|
llm LLMProvider |
|
129
|
log *slog.Logger |
|
130
|
mu sync.Mutex |
|
131
|
lastReq map[string]time.Time // nick → last request time |
|
132
|
client *girc.Client |
|
133
|
chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported |
|
134
|
} |
|
135
|
|
|
136
|
// New creates an oracle bot. |
|
137
|
func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot { |
|
138
|
return &Bot{ |
|
139
|
ircAddr: ircAddr, |
|
140
|
password: password, |
|
141
|
channels: channels, |
|
142
|
history: history, |
|
143
|
llm: llm, |
|
144
|
log: log, |
|
145
|
lastReq: make(map[string]time.Time), |
|
146
|
} |
|
147
|
} |
|
148
|
|
|
149
|
// Name returns the bot's IRC nick. |
|
150
|
func (b *Bot) Name() string { return botNick } |
|
151
|
|
|
152
|
// Start connects to IRC and begins serving summarization requests. |
|
153
|
func (b *Bot) Start(ctx context.Context) error { |
|
154
|
host, port, err := splitHostPort(b.ircAddr) |
|
155
|
if err != nil { |
|
156
|
return fmt.Errorf("oracle: parse irc addr: %w", err) |
|
157
|
} |
|
158
|
|
|
159
|
c := girc.New(girc.Config{ |
|
160
|
Server: host, |
|
161
|
Port: port, |
|
162
|
Nick: botNick, |
|
163
|
User: botNick, |
|
164
|
Name: "scuttlebot oracle", |
|
165
|
SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
|
166
|
PingDelay: 30 * time.Second, |
|
167
|
PingTimeout: 30 * time.Second, |
|
168
|
SSL: false, |
|
169
|
SupportedCaps: map[string][]string{ |
|
170
|
"draft/chathistory": nil, |
|
171
|
"chathistory": nil, |
|
172
|
}, |
|
173
|
}) |
|
174
|
|
|
175
|
b.chFetch = chathistory.New(c) |
|
176
|
|
|
177
|
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
|
178
|
cl.Cmd.Mode(cl.GetNick(), "+B") |
|
179
|
for _, ch := range b.channels { |
|
180
|
cl.Cmd.Join(ch) |
|
181
|
} |
|
182
|
hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory") |
|
183
|
if b.log != nil { |
|
184
|
b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH) |
|
185
|
} |
|
186
|
}) |
|
187
|
|
|
188
|
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
|
189
|
if ch := e.Last(); strings.HasPrefix(ch, "#") { |
|
190
|
cl.Cmd.Join(ch) |
|
191
|
} |
|
192
|
}) |
|
193
|
|
|
194
|
router := cmdparse.NewRouter(botNick) |
|
195
|
router.Register(cmdparse.Command{ |
|
196
|
Name: "summarize", |
|
197
|
Usage: "SUMMARIZE [#channel] [duration]", |
|
198
|
Description: "summarize recent channel activity", |
|
199
|
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" }, |
|
200
|
}) |
|
201
|
|
|
202
|
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { |
|
203
|
if len(e.Params) < 1 || e.Source == nil { |
|
204
|
return |
|
205
|
} |
|
206
|
// Dispatch commands (DMs and channel messages). |
|
207
|
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil { |
|
208
|
cl.Cmd.Message(reply.Target, reply.Text) |
|
209
|
return |
|
210
|
} |
|
211
|
target := e.Params[0] |
|
212
|
if strings.HasPrefix(target, "#") { |
|
213
|
return // channel message — ignore |
|
214
|
} |
|
215
|
nick := e.Source.Name |
|
216
|
text := e.Last() |
|
217
|
|
|
218
|
go b.handle(ctx, cl, nick, text) |
|
219
|
}) |
|
220
|
|
|
221
|
b.client = c |
|
222
|
|
|
223
|
errCh := make(chan error, 1) |
|
224
|
go func() { |
|
225
|
if err := c.Connect(); err != nil && ctx.Err() == nil { |
|
226
|
errCh <- err |
|
227
|
} |
|
228
|
}() |
|
229
|
|
|
230
|
select { |
|
231
|
case <-ctx.Done(): |
|
232
|
c.Close() |
|
233
|
return nil |
|
234
|
case err := <-errCh: |
|
235
|
return fmt.Errorf("oracle: irc connection: %w", err) |
|
236
|
} |
|
237
|
} |
|
238
|
|
|
239
|
// Stop disconnects the bot. |
|
240
|
func (b *Bot) Stop() { |
|
241
|
if b.client != nil { |
|
242
|
b.client.Close() |
|
243
|
} |
|
244
|
} |
|
245
|
|
|
246
|
func (b *Bot) handle(ctx context.Context, cl *girc.Client, nick, text string) { |
|
247
|
req, err := ParseCommand(text) |
|
248
|
if err != nil { |
|
249
|
cl.Cmd.Notice(nick, "oracle: "+err.Error()) |
|
250
|
return |
|
251
|
} |
|
252
|
|
|
253
|
// Rate limit. |
|
254
|
b.mu.Lock() |
|
255
|
if last, ok := b.lastReq[nick]; ok && time.Since(last) < rateLimitWait { |
|
256
|
wait := rateLimitWait - time.Since(last) |
|
257
|
b.mu.Unlock() |
|
258
|
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: rate limited — try again in %s", wait.Round(time.Second))) |
|
259
|
return |
|
260
|
} |
|
261
|
b.lastReq[nick] = time.Now() |
|
262
|
b.mu.Unlock() |
|
263
|
|
|
264
|
// Fetch history — prefer CHATHISTORY if available, fall back to store. |
|
265
|
entries, err := b.fetchHistory(ctx, req.Channel, req.Limit) |
|
266
|
if err != nil { |
|
267
|
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err)) |
|
268
|
return |
|
269
|
} |
|
270
|
if len(entries) == 0 { |
|
271
|
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: no history found for %s", req.Channel)) |
|
272
|
return |
|
273
|
} |
|
274
|
|
|
275
|
// Build prompt. |
|
276
|
prompt := buildPrompt(req.Channel, entries) |
|
277
|
|
|
278
|
// Call LLM. |
|
279
|
summary, err := b.llm.Summarize(ctx, prompt) |
|
280
|
if err != nil { |
|
281
|
cl.Cmd.Notice(nick, "oracle: summarization failed: "+err.Error()) |
|
282
|
return |
|
283
|
} |
|
284
|
|
|
285
|
// Format and deliver. |
|
286
|
response := formatResponse(req.Channel, len(entries), summary, req.Format) |
|
287
|
for _, line := range strings.Split(response, "\n") { |
|
288
|
if line != "" { |
|
289
|
cl.Cmd.Notice(nick, line) |
|
290
|
} |
|
291
|
} |
|
292
|
} |
|
293
|
|
|
294
|
func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) { |
|
295
|
if b.chFetch != nil && b.client != nil { |
|
296
|
hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory") |
|
297
|
if hasCH { |
|
298
|
chCtx, cancel := context.WithTimeout(ctx, 10*time.Second) |
|
299
|
defer cancel() |
|
300
|
msgs, err := b.chFetch.Latest(chCtx, channel, limit) |
|
301
|
if err == nil { |
|
302
|
entries := make([]HistoryEntry, len(msgs)) |
|
303
|
for i, m := range msgs { |
|
304
|
nick := m.Nick |
|
305
|
if m.Account != "" { |
|
306
|
nick = m.Account |
|
307
|
} |
|
308
|
entries[i] = HistoryEntry{ |
|
309
|
Nick: nick, |
|
310
|
Raw: m.Text, |
|
311
|
} |
|
312
|
} |
|
313
|
return entries, nil |
|
314
|
} |
|
315
|
if b.log != nil { |
|
316
|
b.log.Warn("chathistory failed, falling back to store", "err", err) |
|
317
|
} |
|
318
|
} |
|
319
|
} |
|
320
|
return b.history.Query(channel, limit) |
|
321
|
} |
|
322
|
|
|
323
|
func buildPrompt(channel string, entries []HistoryEntry) string { |
|
324
|
// Convert to TOON entries for token-efficient LLM context. |
|
325
|
toonEntries := make([]toon.Entry, len(entries)) |
|
326
|
for i, e := range entries { |
|
327
|
toonEntries[i] = toon.Entry{ |
|
328
|
Nick: e.Nick, |
|
329
|
MessageType: e.MessageType, |
|
330
|
Text: e.Raw, |
|
331
|
} |
|
332
|
} |
|
333
|
return toon.FormatPrompt(channel, toonEntries) |
|
334
|
} |
|
335
|
|
|
336
|
func formatResponse(channel string, count int, summary string, format Format) string { |
|
337
|
switch format { |
|
338
|
case FormatJSON: |
|
339
|
// Simple JSON — avoid encoding/json dependency in the hot path. |
|
340
|
return fmt.Sprintf(`{"channel":%q,"messages":%d,"format":"json","summary":%q}`, |
|
341
|
channel, count, summary) |
|
342
|
default: // TOON |
|
343
|
return fmt.Sprintf("--- oracle summary: %s (%d messages) ---\n%s\n--- end ---", |
|
344
|
channel, count, summary) |
|
345
|
} |
|
346
|
} |
|
347
|
|
|
348
|
func splitHostPort(addr string) (string, int, error) { |
|
349
|
host, portStr, err := net.SplitHostPort(addr) |
|
350
|
if err != nil { |
|
351
|
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
|
352
|
} |
|
353
|
port, err := strconv.Atoi(portStr) |
|
354
|
if err != nil { |
|
355
|
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) |
|
356
|
} |
|
357
|
return host, port, nil |
|
358
|
} |
|
359
|
|