Files
talk/internal/queue/rate.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

210 lines
6.8 KiB
Go

package queue
import (
"strings"
"sync"
"time"
"unicode"
)
// Discard reasons in the HTTP contract of POST /speak
// (202 {"id":"","discarded":"…"}).
const (
DiscardRateLimit = "rate_limit" // priority=low over the window budget
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
)
// RateLimiter is the time window of the daemon's anti-spam law: default
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
// started announcement books the window anew (Reserve), including ones that
// broke out via now or high burst: the window counts playback starts, not
// their legitimacy.
//
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
// queue lock during Allow/Reserve/Next, never the other way round.
type RateLimiter struct {
// Interval is the minimum gap between two announcement starts. 0 or
// negative = unlimited.
Interval time.Duration
mu sync.Mutex
last time.Time // start of the announcement last booked via Reserve
}
// Allow reports whether an announcement could start right now.
func (l *RateLimiter) Allow() bool {
if l == nil || l.Interval <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
return time.Since(l.last) >= l.Interval
}
// Reserve books a slot. Call it right after the play decision, before actual
// playback: failed announcements consume the window too, so a broken
// provider cannot turn into a machine gun.
func (l *RateLimiter) Reserve() {
if l == nil || l.Interval <= 0 {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.last = time.Now()
}
// Next returns when the next slot opens; the zero time when Allow would
// return true right away (unlimited or window elapsed). Basis for parking
// waiting items and for the status endpoint.
func (l *RateLimiter) Next() time.Time {
if l == nil || l.Interval <= 0 {
return time.Time{}
}
l.mu.Lock()
defer l.mu.Unlock()
if l.last.IsZero() {
return time.Time{}
}
return l.last.Add(l.Interval)
}
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
// rights for high, quiet hours, text length cap and maximum queue age. The
// zero value allows everything; the daemon supplies the laws from the
// config (defaults in internal/config.Defaults).
type Laws struct {
// RateLimit is the minimum gap between two announcement starts
// (0 = unlimited).
RateLimit time.Duration
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Without burst rights, high waits like normal. Quiet
// hours apply to high regardless.
AllowHighBurst bool
// Quiet is the quiet window (local time; zero value = disabled).
Quiet QuietHours
// QuietMode is the behaviour during quiet hours: "discard" drops
// silently, "toast" replaces audio with a desktop notification (the
// queue fires the OnToast hook; the daemon wires the notification).
QuietMode string
// MaxText caps announcement length in characters (runes). Longer texts
// are cut at a word boundary on admission and reported as truncated: an
// agent reading out its long answer costs neither minutes nor API
// budget, and the user still hears the beginning. 0 = unlimited.
MaxText int
// MaxAge is the longest an announcement may wait in the queue. Whatever
// parks longer (rate limit, backlog) is stale and gets discarded when it
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
// never waits long and is exempt.
MaxAge time.Duration
}
// verdict is the laws' decision about a due item.
type verdict int
const (
verdictPlay verdict = iota // play
verdictWait // park until the slot opens
verdictDiscardRate // discard: low over budget
verdictDiscardQuiet // discard: quiet hours (discard)
verdictToast // toast instead of audio (quiet hours)
verdictDiscardExpired // discard: waited longer than MaxAge
)
// decide applies the laws to a due item (pure, no state): slotFree says
// whether the rate-limit slot is open right now. Precedence:
//
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
// (the documented escape for urgent matters; it still books a new rate
// window).
// 2. Stale: an item that waited longer than MaxAge is discarded; an
// announcement from five minutes ago is no longer news.
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
// because burst is a rate right, not a quiet right.
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
// discarded; normal (and high without rights) park until the slot.
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
if item.Now {
return verdictPlay
}
if l.MaxAge > 0 && !item.enqueued.IsZero() && now.Sub(item.enqueued) > l.MaxAge {
return verdictDiscardExpired
}
if l.Quiet.Active(now) {
if l.QuietMode == QuietModeToast {
return verdictToast
}
return verdictDiscardQuiet
}
if slotFree {
return verdictPlay
}
if item.Priority == PriorityHigh && l.AllowHighBurst {
return verdictPlay
}
if item.Priority == PriorityLow {
return verdictDiscardRate
}
return verdictWait
}
// Admission is the result of Enqueue: admitted items carry their ID,
// discarded ones name the reason (the 202 response of POST /speak carries
// it).
type Admission struct {
// ID of the admitted announcement (empty when discarded).
ID string
// Discarded is the discard reason (DiscardRateLimit or
// DiscardQuietHours); empty when the announcement was admitted.
Discarded string
// Toast reports that the announcement was replaced by a notification
// during quiet hours (quiet_mode = "toast").
Toast bool
// Truncated reports that the text was cut to MaxText (the shortened
// version was admitted).
Truncated bool
}
// LawState is the /status view of the laws: the configured rate limit with
// the next open slot, the quiet window, and the length and age limits.
type LawState struct {
RateLimit time.Duration
AllowHighBurst bool
NextSlot time.Time // zero = slot open (or unlimited)
Quiet QuietHours
QuietActive bool
QuietMode string
QuietUntil time.Time // zero = quiet hours inactive
MaxText int // 0 = unlimited
MaxAge time.Duration // 0 = unlimited
}
// truncateText cuts s to at most max runes, preferably at a word boundary
// (unless that lies in the first half), and reports whether it cut.
// max <= 0 = unlimited.
func truncateText(s string, max int) (string, bool) {
if max <= 0 {
return s, false
}
runes := []rune(s)
if len(runes) <= max {
return s, false
}
cut := max
for i := max; i > max/2; i-- {
if unicode.IsSpace(runes[i]) {
cut = i
break
}
}
return strings.TrimRight(string(runes[:cut]), " \t\r\n,;:-–—"), true
}