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]>
This commit is contained in:
1 parent
b1ea5f17e3
commit
7ae1fa5747
21 files changed
+2423
-2
No files matched your search
@@ -0,0 +1,262 @@
|
||||
// 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 }
|
||||
Reference in new issue
Block a user