ScuttleBot

scuttlebot / internal / bots / oracle / oracle.go
Blame History Raw 359 lines
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

Keyboard Shortcuts

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