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:
FernandoJVideiraandClaude Opus 5.5 committed 2026-10-20 18:46:18 +01:00
1 parent b1ea5f17e3
commit 7ae1fa5747
21 files changed
+2423 -2

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
package tldrrepo
import (
"context"
"database/sql"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
// GetCursors returns channel_id -> last_message_id for one user in one guild.
func (r *Repo) GetCursors(ctx context.Context, userID, guildID int64) (map[int64]int64, error) {
const q = `
SELECT channel_id, last_message_id
FROM tldr_cursors
WHERE user_id = $1 AND guild_id = $2
`
rows, err := r.db.QueryContext(ctx, q, userID, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]int64)
for rows.Next() {
var channelID, messageID int64
if err := rows.Scan(&channelID, &messageID); err != nil {
return nil, err
}
out[channelID] = messageID
}
return out, rows.Err()
}
// SetCursors upserts the given channel cursors in one transaction. GREATEST
// means a cursor only ever moves forward, so two overlapping runs can't drag
// it back and re-summarize messages.
func (r *Repo) SetCursors(ctx context.Context, userID, guildID int64, cursors map[int64]int64) error {
if len(cursors) == 0 {
return nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
const q = `
INSERT INTO tldr_cursors (user_id, channel_id, guild_id, last_message_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, channel_id)
DO UPDATE SET
last_message_id = GREATEST(tldr_cursors.last_message_id, EXCLUDED.last_message_id),
updated_at = NOW()
`
for channelID, messageID := range cursors {
if _, err := tx.ExecContext(ctx, q, userID, channelID, guildID, messageID); err != nil {
return err
}
}
return tx.Commit()
}