/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]>
56 lines
1.4 KiB
Go
56 lines
1.4 KiB
Go
package tldr
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const discordEpochMs = 1420070400000
|
|
|
|
var sinceRe = regexp.MustCompile(`^(\d+)([mhd])$`)
|
|
|
|
// ParseSince turns "30m", "6h" or "2d" into a duration, clamped to
|
|
// [1m, maxWindow]. time.ParseDuration has no day unit, hence the custom parser.
|
|
func ParseSince(s string) (time.Duration, error) {
|
|
m := sinceRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(s)))
|
|
if m == nil {
|
|
return 0, fmt.Errorf("couldn't read %q, use something like 30m, 6h or 2d", s)
|
|
}
|
|
n, err := strconv.Atoi(m[1])
|
|
if err != nil || n <= 0 {
|
|
return 0, fmt.Errorf("couldn't read %q, use something like 30m, 6h or 2d", s)
|
|
}
|
|
|
|
unit := map[string]time.Duration{"m": time.Minute, "h": time.Hour, "d": 24 * time.Hour}[m[2]]
|
|
if n > int(maxWindow/unit) {
|
|
return maxWindow, nil
|
|
}
|
|
return max(time.Duration(n)*unit, time.Minute), nil
|
|
}
|
|
|
|
// SnowflakeFromTime returns the smallest Discord snowflake for t, usable as
|
|
// an "after" cursor to mean "messages posted after this moment".
|
|
func SnowflakeFromTime(t time.Time) string {
|
|
ms := max(t.UnixMilli()-discordEpochMs, 0)
|
|
return strconv.FormatInt(ms<<22, 10)
|
|
}
|
|
|
|
// CompareIDs compares two snowflakes numerically (-1, 0, 1).
|
|
func CompareIDs(a, b string) int {
|
|
ai, aerr := strconv.ParseUint(a, 10, 64)
|
|
bi, berr := strconv.ParseUint(b, 10, 64)
|
|
if aerr != nil || berr != nil {
|
|
return strings.Compare(a, b)
|
|
}
|
|
switch {
|
|
case ai < bi:
|
|
return -1
|
|
case ai > bi:
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|