148 lines
5.8 KiB
Go
148 lines
5.8 KiB
Go
package backthink
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
)
|
|
|
|
// Defaults applied by normalize when a Config field is left at its zero value.
|
|
// They are compile-time constants so no magic number appears in the run path.
|
|
const (
|
|
// defaultIntervalSec is the heartbeat period in seconds when unused
|
|
// (15 minutes: the autonomous reflection is deliberately low-frequency).
|
|
defaultIntervalSec = 900
|
|
// defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb
|
|
// window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may
|
|
// wrap midnight; two equal non-zero hours disable it).
|
|
defaultQuietStartHour = 23
|
|
defaultQuietEndHour = 7
|
|
// defaultPushLimit caps proactive pushes within defaultPushWindow to honor
|
|
// the anti-spam rule of design/11 §6 (QQ wind control, iLink window). The
|
|
// default drops to 1 so the owner is interrupted at most once per hour.
|
|
defaultPushLimit = 1
|
|
defaultPushWindow = 60 * time.Minute
|
|
// defaultPushCooldown is the quiet gap after a successful proactive push,
|
|
// so a successful delivery is not immediately repeated.
|
|
defaultPushCooldown = 30 * time.Minute
|
|
// defaultRecentChatCooldown is the gap after the owner's last message within
|
|
// which an autonomous push is held, so a fresh conversation is not interrupted.
|
|
defaultRecentChatCooldown = 10 * time.Minute
|
|
// defaultKindWindow bounds how often any single push flavor (问候/提醒/分享/
|
|
// 关心) may repeat, so the same style cannot spam within it.
|
|
defaultKindWindow = 2 * time.Hour
|
|
// defaultThinkTimeout bounds a single unattended thought so it can never
|
|
// wedge a worker goroutine.
|
|
defaultThinkTimeout = 30 * time.Second
|
|
// defaultSessionID scopes a think when the caller supplies none. It is the
|
|
// loop.Run durability key, deliberately distinct from history.MainSessionID so
|
|
// an autonomous think never collides with the live main session's checkpoint
|
|
// store (channelbridge saves/resumes under the main id). The think still sees
|
|
// the main conversation: LoopThinker reads history.MainSessionID for context and
|
|
// runs under loop.OwnerEnv (design/20 §2).
|
|
defaultSessionID = "sess-backthink"
|
|
)
|
|
|
|
// DefaultSessionID is the exported loop.Run session key for an autonomous think. It is
|
|
// exposed so the flow-viz publisher (cmd/cyrene) can also publish the backthink flow
|
|
// graph, which the backthink page and the web-chat flow page both render.
|
|
const DefaultSessionID = "sess-backthink"
|
|
|
|
// Config carries the autonomous-think tuning knobs. Zero values pick safe
|
|
// defaults via normalize, so a partially filled Config is still usable.
|
|
type Config struct {
|
|
// Interval is the heartbeat period. When zero it is derived from
|
|
// IntervalSec (or its default).
|
|
Interval time.Duration
|
|
// IntervalSec is the heartbeat period in seconds, mapped from
|
|
// config.BackgroundConfig.IntervalSec. Ignored when Interval is set.
|
|
IntervalSec int
|
|
// QuietStartHour and QuietEndHour bound the do-not-disturb window in local
|
|
// clock hours. Equal non-zero hours disable the window.
|
|
QuietStartHour int
|
|
QuietEndHour int
|
|
// PushLimit bounds proactive pushes within PushWindow. Zero uses the default.
|
|
// Non-positive PushWindow or PushLimit disables the limit.
|
|
PushLimit int
|
|
// PushWindow is the sliding window over which PushLimit is enforced.
|
|
PushWindow time.Duration
|
|
// PushCooldown is the quiet gap after a successful proactive push before
|
|
// another is allowed. Zero uses the default.
|
|
PushCooldown time.Duration
|
|
// RecentChatCooldown is the gap after the owner's last message within which an
|
|
// autonomous push is held. Zero uses the default.
|
|
RecentChatCooldown time.Duration
|
|
// KindWindow bounds how often any single push flavor may repeat. Zero uses the
|
|
// default.
|
|
KindWindow time.Duration
|
|
// ThinkTimeout bounds a single thinking pass. Zero uses the default.
|
|
ThinkTimeout time.Duration
|
|
}
|
|
|
|
// ConfigFromBackground maps a config.BackgroundConfig (the source of the heartbeat
|
|
// interval, the shared quiet-hours window and the proactive-push frequency knobs)
|
|
// into a backthink.Config, leaving anything the env does not expose at its
|
|
// defaults for normalize to fill in.
|
|
func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
|
return Config{
|
|
IntervalSec: bg.IntervalSec,
|
|
QuietStartHour: bg.QuietStartHour,
|
|
QuietEndHour: bg.QuietEndHour,
|
|
PushLimit: bg.PushLimit,
|
|
PushWindow: time.Duration(bg.PushWindowSec) * time.Second,
|
|
PushCooldown: time.Duration(bg.PushCooldownSec) * time.Second,
|
|
RecentChatCooldown: time.Duration(bg.RecentChatCooldownSec) * time.Second,
|
|
KindWindow: time.Duration(bg.KindWindowSec) * time.Second,
|
|
}
|
|
}
|
|
|
|
// normalize fills a Config's zero fields with safe defaults.
|
|
func normalize(cfg Config) Config {
|
|
if cfg.IntervalSec <= 0 {
|
|
cfg.IntervalSec = defaultIntervalSec
|
|
}
|
|
if cfg.Interval <= 0 {
|
|
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
|
|
}
|
|
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
|
|
cfg.QuietStartHour = defaultQuietStartHour
|
|
cfg.QuietEndHour = defaultQuietEndHour
|
|
}
|
|
if cfg.PushLimit <= 0 {
|
|
cfg.PushLimit = defaultPushLimit
|
|
}
|
|
if cfg.PushWindow <= 0 {
|
|
cfg.PushWindow = defaultPushWindow
|
|
}
|
|
if cfg.PushCooldown <= 0 {
|
|
cfg.PushCooldown = defaultPushCooldown
|
|
}
|
|
if cfg.RecentChatCooldown <= 0 {
|
|
cfg.RecentChatCooldown = defaultRecentChatCooldown
|
|
}
|
|
if cfg.KindWindow <= 0 {
|
|
cfg.KindWindow = defaultKindWindow
|
|
}
|
|
if cfg.ThinkTimeout <= 0 {
|
|
cfg.ThinkTimeout = defaultThinkTimeout
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// quietHoursLabel renders the do-not-disturb window for logs.
|
|
func quietHoursLabel(start, end int) string {
|
|
return fmt.Sprintf("%02d:00-%02d:00", start, end)
|
|
}
|
|
|
|
// baseLog returns l when non-nil, otherwise a discard logger, so New always has a
|
|
// safe logger to tag.
|
|
func baseLog(l *slog.Logger) *slog.Logger {
|
|
if l != nil {
|
|
return l
|
|
}
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|