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)) }