diff --git a/internal/config/config.go b/internal/config/config.go index b6f83ce..f9e1956 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -279,52 +279,6 @@ type Config struct { NetScan *NetScanConfig `json:"netscan,omitempty"` } -// DigestConfig — notification batching / digest mode. When enabled, eligible -// nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent -// immediately. Every Window duration (or when MaxItems reached), the queue is -// flushed as a single digest notification. nil ⇒ digest disabled (legacy -// behaviour — every nudge is sent as it fires). -type DigestConfig struct { - Enabled bool `json:"enabled,omitempty"` - Window Duration `json:"window,omitempty"` // e.g. "30m" - MaxItems int `json:"max_items,omitempty"` // flush at this count - SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched -} - -// PatternProposalConfig — announcement policy for routines the digestion tick -// inferred by itself (Vikunja #247, #43). -// -// Detection is always on and always silent by default: the tick writes a -// proposed_routines row and the /routines page shows it. Notify is what turns -// "she noticed" into "she said something", and it is OFF unless configured — -// Maven is not a nag and not autonomous, so a behaviour that speaks without -// being asked has to be switched on deliberately, like weather and telegram. -// -// When Notify is on, the announcement is still heavily restrained: -// - at most one proposal per tick, however many were detected; -// - at most one per Cooldown across all pairs (not per pair), so a batch of -// freshly-detected patterns cannot turn into a queue of interruptions; -// - through the ordinary care-class gate (quiet hours / away / snooze), at -// sev1 — the lowest severity there is. A proposal is the least urgent -// thing Maven can say. -// -// A pair is only ever announced once, because it is only ever proposed once: -// proposed_routines is UNIQUE(action, object) and the row survives dismissal. -type PatternProposalConfig struct { - // Notify — announce newly inferred routines. Default false. - Notify bool `json:"notify,omitempty"` - - // Cooldown — minimum spacing between two proposal announcements. 0 ⇒ - // DefaultProposalCooldown (24h). - Cooldown Duration `json:"cooldown,omitempty"` -} - -// AnnounceProposals reports whether inferred routines may be announced. Safe -// on a nil receiver — an absent config block means silent detection. -func (p *PatternProposalConfig) AnnounceProposals() bool { - return p != nil && p.Notify -} - // MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248). // Absent ⇒ off, like every other capability that costs something the owner did // not ask for. Each evaluation is a full LLM round-trip on the one resident @@ -401,11 +355,6 @@ const ( DefaultFactEnrichmentInterval = 30 * time.Second - // DefaultProposalCooldown — one inferred-routine announcement per day at - // most. A proposal is never urgent; if two patterns surface in the same - // hour, the second one waits, and the /routines page has it either way. - DefaultProposalCooldown = 24 * time.Hour - // DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory // evaluation loop, applied only when the block is present at all. DefaultMemoryEvalInterval = time.Hour @@ -471,24 +420,8 @@ func (c *Config) applyDefaults() { } } - if c.Digest == nil { - c.Digest = &DigestConfig{Enabled: false} - } - if c.Digest.Window == 0 { - c.Digest.Window = Duration(30 * time.Minute) - } - if c.Digest.MaxItems == 0 { - c.Digest.MaxItems = 5 - } - if c.Digest.SeverityCeiling == 0 { - c.Digest.SeverityCeiling = 2 - } - - // Absent block stays nil (⇒ silent detection). Present-but-partial gets the - // cooldown default, so `{"notify": true}` is enough to switch it on. - if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 { - c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown) - } + c.normaliseDigest() + c.normalisePatternProposals() // Same rule: absent stays nil (⇒ no evaluation loop), present gets defaults // so `{}` is a valid "on with the plan's cadence". diff --git a/internal/config/notify.go b/internal/config/notify.go new file mode 100644 index 0000000..288c474 --- /dev/null +++ b/internal/config/notify.go @@ -0,0 +1,91 @@ +package config + +import "time" + +// The two blocks that shape what reaches him unasked: how nudges are batched, +// and whether a routine Maven inferred by herself may be announced at all. + +// DigestConfig — notification batching / digest mode. When enabled, eligible +// nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent +// immediately. Every Window duration (or when MaxItems reached), the queue is +// flushed as a single digest notification. nil ⇒ digest disabled (legacy +// behaviour — every nudge is sent as it fires). +type DigestConfig struct { + Enabled bool `json:"enabled,omitempty"` + Window Duration `json:"window,omitempty"` // e.g. "30m" + MaxItems int `json:"max_items,omitempty"` // flush at this count + SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched +} + +// Digest defaults, applied in normaliseDigest. +const ( + DefaultDigestWindow = 30 * time.Minute + DefaultDigestMaxItems = 5 + DefaultDigestSeverityCeiling = 2 +) + +// normaliseDigest is the one block that does NOT fold an absent block to nil: +// it materialises a disabled one instead, because the dispatcher reads +// c.Digest.Enabled without a nil check. +func (c *Config) normaliseDigest() { + if c.Digest == nil { + c.Digest = &DigestConfig{Enabled: false} + } + if c.Digest.Window == 0 { + c.Digest.Window = Duration(DefaultDigestWindow) + } + if c.Digest.MaxItems == 0 { + c.Digest.MaxItems = DefaultDigestMaxItems + } + if c.Digest.SeverityCeiling == 0 { + c.Digest.SeverityCeiling = DefaultDigestSeverityCeiling + } +} + +// PatternProposalConfig — announcement policy for routines the digestion tick +// inferred by itself (Vikunja #247, #43). +// +// Detection is always on and always silent by default: the tick writes a +// proposed_routines row and the /routines page shows it. Notify is what turns +// "she noticed" into "she said something", and it is OFF unless configured — +// Maven is not a nag and not autonomous, so a behaviour that speaks without +// being asked has to be switched on deliberately, like weather and telegram. +// +// When Notify is on, the announcement is still heavily restrained: +// - at most one proposal per tick, however many were detected; +// - at most one per Cooldown across all pairs (not per pair), so a batch of +// freshly-detected patterns cannot turn into a queue of interruptions; +// - through the ordinary care-class gate (quiet hours / away / snooze), at +// sev1 — the lowest severity there is. A proposal is the least urgent +// thing Maven can say. +// +// A pair is only ever announced once, because it is only ever proposed once: +// proposed_routines is UNIQUE(action, object) and the row survives dismissal. +type PatternProposalConfig struct { + // Notify — announce newly inferred routines. Default false. + Notify bool `json:"notify,omitempty"` + + // Cooldown — minimum spacing between two proposal announcements. 0 ⇒ + // DefaultProposalCooldown (24h). + Cooldown Duration `json:"cooldown,omitempty"` +} + +// DefaultProposalCooldown — one inferred-routine announcement per day at +// most. A proposal is never urgent; if two patterns surface in the same +// hour, the second one waits, and the /routines page has it either way. +const DefaultProposalCooldown = 24 * time.Hour + +// AnnounceProposals reports whether inferred routines may be announced. Safe +// on a nil receiver — an absent config block means silent detection. +func (p *PatternProposalConfig) AnnounceProposals() bool { + return p != nil && p.Notify +} + +// normalisePatternProposals leaves an absent block nil, which means silent +// detection. A present-but-partial one gets the cooldown, so `{"notify": true}` +// is enough to switch announcements on. +func (c *Config) normalisePatternProposals() { + if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 { + c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown) + } +}