ScuttleBot

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

Keyboard Shortcuts

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