Files
FernandoJVideiraandClaude Opus 5.5 7ae1fa5747 Add /tldr: a private overview of what you missed in a channel
/tldr [since] summarizes the messages in the channel it is run in that the
caller has not been summarized yet, using a small local model on Ollama, and
replies ephemerally. The answer streams into the reply as it is written.

- Per-user, per-channel cursor (tldr_cursors) so each run picks up where the
  last left off; `since:` (e.g. 6h, 2d, max 7d) peeks without moving it.
  First use covers the last 24h, and a stale cursor never reaches back more
  than 7 days.
- Only summarizes channels both the caller and the bot can read.
- The transcript drops bots, system messages, reactions and filler, turns
  links, GIFs, images and files into short tags ([link: site], [image],
  [file: name], preview titles), marks replies, and writes a date marker
  where the chat paused for 3h or more so the model can keep topics apart.
- The prompt asks for one bullet per conversation, oldest first, with a
  worked example; it was tuned against a synthetic chat and a real export
  with qwen3:4b-instruct-2507 (the default model).
- One model call at a time across the bot, one /tldr per user.
- docker-compose: Ollama service (CPU only, 8g memory cap: 6g killed the
  runner mid-request), model pull on startup.

Needs the tldr_cursors migration (velox-dashboard-api 000016).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 18:46:18 +01:00

263 lines
7.9 KiB
Go

// Package tldr implements the /tldr pipeline: find what a user hasn't seen
// in the channel they ran the command in, turn it into a prompt-sized
// transcript, and have a local model summarize it. Messages are fetched on
// demand and never stored; the only thing persisted is a per-user,
// per-channel "read up to" cursor.
package tldr
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
"github.com/bwmarrin/discordgo"
)
const (
defaultWindow = 24 * time.Hour // first use in a channel, no cursor yet
maxWindow = 7 * 24 * time.Hour // a stale cursor is never trusted further back than this
runTimeout = 12 * time.Minute // interaction tokens live 15m
partialEvery = 2 * time.Second // minimum gap between edits while the answer streams in
)
type Discord interface {
MessageFetcher
UserChannelPermissions(userID, channelID string, fetchOptions ...discordgo.RequestOption) (int64, error)
}
type CursorStore interface {
GetCursors(ctx context.Context, userID, guildID int64) (map[int64]int64, error)
SetCursors(ctx context.Context, userID, guildID int64, cursors map[int64]int64) error
}
type Request struct {
GuildID string
ChannelID string // the channel the command was run in, and the only one summarized
UserID string
BotID string
// UserPerms is the caller's permission bitfield in ChannelID, as Discord
// computes it and sends it with the interaction (this also covers threads).
UserPerms int64
// Since, when non-zero, overrides the stored cursors for this run and
// leaves them untouched ("peek" mode).
Since time.Duration
Now time.Time
}
type Result struct {
Summary string
Messages int
Truncated bool
Empty bool
}
var (
// ErrUserCannotRead means the caller lacks View Channel or Read Message
// History in the channel: summarizing it would hand them messages they
// cannot read themselves.
ErrUserCannotRead = errors.New("the caller cannot read this channel's history")
// ErrBotCannotRead means the bot is missing those permissions here.
ErrBotCannotRead = errors.New("the bot cannot read this channel's history")
)
var (
defaultSummarizer Summarizer
// One model call at a time: the box has no GPU and a single Ollama slot,
// so parallel runs would just fight each other (and Lavalink) for CPU.
slot = make(chan struct{}, 1)
inFlight sync.Map
)
func Init(s Summarizer) { defaultSummarizer = s }
func Default() Summarizer { return defaultSummarizer }
// TryStart reserves the user's single concurrent /tldr. Done releases it.
func TryStart(userID string) bool {
_, loaded := inFlight.LoadOrStore(userID, struct{}{})
return !loaded
}
func Done(userID string) { inFlight.Delete(userID) }
const needPerms = discordgo.PermissionViewChannel | discordgo.PermissionReadMessageHistory
func forbidden(err error) bool {
var re *discordgo.RESTError
return errors.As(err, &re) && re.Response != nil && re.Response.StatusCode == http.StatusForbidden
}
// Gather collects the messages in the request's channel that the user hasn't
// been summarized yet, as a single batch (nil when there are none).
func Gather(ctx context.Context, d Discord, store CursorStore, req Request) ([]ChannelBatch, error) {
guildID, err := strconv.ParseInt(req.GuildID, 10, 64)
if err != nil {
return nil, fmt.Errorf("bad guild id: %w", err)
}
userID, err := strconv.ParseInt(req.UserID, 10, 64)
if err != nil {
return nil, fmt.Errorf("bad user id: %w", err)
}
channelID, err := strconv.ParseInt(req.ChannelID, 10, 64)
if err != nil {
return nil, fmt.Errorf("bad channel id: %w", err)
}
if req.UserPerms&needPerms != needPerms {
return nil, ErrUserCannotRead
}
// A lookup error is not proof of missing access (the fetch below reports
// a real 403), so only a successful lookup that lacks the bits refuses.
if perms, err := d.UserChannelPermissions(req.BotID, req.ChannelID); err == nil && perms&needPerms != needPerms {
return nil, ErrBotCannotRead
}
var after string
if req.Since > 0 {
after = SnowflakeFromTime(req.Now.Add(-req.Since))
} else {
cursors, err := store.GetCursors(ctx, userID, guildID)
if err != nil {
return nil, fmt.Errorf("loading cursors: %w", err)
}
if c, ok := cursors[channelID]; ok {
after = strconv.FormatInt(c, 10)
// A cursor from months ago would mean a huge backlog.
if floor := SnowflakeFromTime(req.Now.Add(-maxWindow)); CompareIDs(after, floor) < 0 {
after = floor
}
} else {
after = SnowflakeFromTime(req.Now.Add(-defaultWindow))
}
}
msgs, err := FetchAfter(d, req.ChannelID, after, maxMessages)
if err != nil {
if forbidden(err) {
return nil, ErrBotCannotRead
}
return nil, fmt.Errorf("fetching messages: %w", err)
}
if len(msgs) == 0 {
return nil, nil
}
return []ChannelBatch{{ID: req.ChannelID, Name: "", Messages: msgs}}, nil
}
// Commit advances the user's cursors to what this run covered.
func Commit(ctx context.Context, store CursorStore, req Request, lastProcessed map[string]string) error {
guildID, err := strconv.ParseInt(req.GuildID, 10, 64)
if err != nil {
return err
}
userID, err := strconv.ParseInt(req.UserID, 10, 64)
if err != nil {
return err
}
cursors := make(map[int64]int64, len(lastProcessed))
for ch, msg := range lastProcessed {
chID, err1 := strconv.ParseInt(ch, 10, 64)
msgID, err2 := strconv.ParseInt(msg, 10, 64)
if err1 != nil || err2 != nil {
continue
}
cursors[chID] = msgID
}
return store.SetCursors(ctx, userID, guildID, cursors)
}
// throttle lets the first call through at once and then at most one call per
// interval, so a streaming answer can update a Discord message without
// tripping its rate limit. Calls in between are dropped, which is fine
// because each carries the full text so far and a newer one always follows.
func throttle(every time.Duration, now func() time.Time, f func(string)) func(string) {
if f == nil {
return nil
}
var mu sync.Mutex
var last time.Time
return func(s string) {
mu.Lock()
defer mu.Unlock()
if t := now(); last.IsZero() || t.Sub(last) >= every {
last = t
f(s)
}
}
}
// Run is the whole command minus Discord's interaction plumbing. status is
// called with short progress text for anything slow enough to look hung, and
// partial (optional) with the overview so far while it is being written.
func Run(ctx context.Context, d Discord, store CursorStore, sum Summarizer, req Request, status func(string), partial func(string)) (*Result, error) {
if sum == nil {
return nil, errors.New("the summarizer isn't configured")
}
if status == nil {
status = func(string) {}
}
if req.Now.IsZero() {
req.Now = time.Now()
}
peek := req.Since > 0
batches, err := Gather(ctx, d, store, req)
if err != nil {
return nil, err
}
if len(batches) == 0 {
return &Result{Empty: true}, nil
}
tr := BuildTranscript(batches)
if tr.Messages == 0 {
// Only bot/system/empty messages: still move the cursor past them.
if !peek {
if err := Commit(ctx, store, req, tr.LastProcessed); err != nil {
log.Printf("tldr: committing cursors: %v", err)
}
}
return &Result{Empty: true}, nil
}
select {
case slot <- struct{}{}:
default:
status("Another summary is running, you're next in line…")
select {
case slot <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
}
defer func() { <-slot }()
status(fmt.Sprintf("Summarizing %d messages…", tr.Messages))
summary, err := Summarize(ctx, sum, tr.Chunks, func(done, total int) {
if total > 1 {
status(fmt.Sprintf("Summarizing %d messages… (step %d of %d)", tr.Messages, done+1, total))
}
}, throttle(partialEvery, time.Now, partial))
if err != nil {
return nil, err
}
if !peek {
if err := Commit(ctx, store, req, tr.LastProcessed); err != nil {
log.Printf("tldr: committing cursors: %v", err)
}
}
return &Result{Summary: summary, Messages: tr.Messages, Truncated: tr.Truncated}, nil
}
// RunTimeout is the context budget for one /tldr.
func RunTimeout() time.Duration { return runTimeout }