// mavend/tick_routines.go — pattern detection and the routines that fire. // // Split out of tick.go, move-only (Vikunja #422). Configured routines, the // routines he accepted on /routines, and the tick-side detector that proposes // new ones. package main import ( "context" "log" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/routine" ) // detectPatterns runs the pattern detector proactively over every // action+object pair that has ever produced an event, independent of // whichever fact write (or channel) last touched it (Vikunja #43). This is // what makes pattern inference actually proactive: it fires on the daemon's // own schedule reading accumulated history, not only as a side effect of a // live voice turn. // // Idempotence and noise are handled by the store, not here — this function // is safe to call every tick: // - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine // check plus proposed_routines' UNIQUE(action, object) constraint (with // CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that // already has a row — in ANY status — produces no second row and no log // spam beyond the one line at genuine creation. // - A DISMISSED proposal must never come back. DismissProposedRoutine flips // status in place; the row is never deleted. So the same Lookup check // that stops a duplicate "proposed" also stops a "dismissed" one from // resurrecting — there is nothing tick-specific to get right here beyond // calling the same shared path the voice route already used. // // By default this only creates a row for the /routines page to show: it does // not notify, ring, or speak. Detection is not the same act as disturbing him // about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing // is opt-in through the pattern_proposals config block — see announceProposal // for the restraints that apply even then. A proposal only starts producing // recurring nudges once he accepts it (fireAcceptedRoutines). func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) { pairs, err := t.store.DistinctEventPairs(ctx) if err != nil { log.Printf("tick: distinct event pairs: %v", err) return } announced := false for _, p := range pairs { r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now) if err != nil { log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err) continue } if r == nil { continue // no stable pattern, or already proposed/accepted/dismissed } log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays) // One announcement per tick at most, whatever the scan turned up. The // rest are on /routines; they are not lost, they are just not shouted. // Nor are they queued: the row now exists, so no later tick re-detects // them and they are never announced. See announceProposal. if announced { continue } announced = t.announceProposal(ctx, r, now, state) } } // announceProposal offers a freshly inferred routine through the ordinary // care-delivery path, if announcing is switched on at all. Returns true when // something was actually sent. // // Everything here is restraint. The feature is off unless configured; when on // it is sev1 (the lowest severity, so quiet hours, away presence and snooze // all suppress it via loop.Gate exactly like a care nudge); it is spaced by // proposalCfg.Cooldown across every pair, not per pair; and a suppressed or // dropped announcement is NOT retried — the cooldown clock advances only on a // real send, but the proposal row already exists, so the next tick will not // re-detect it and nothing queues up behind it. A missed announcement means // he reads it on /routines instead, which is the whole point of the page. // // What the cooldown is and is not. detectAndPropose returns non-nil only for a // newly created row, so a pair gets exactly one chance to be spoken: the tick // that first proposes it. Combined with one announcement per tick, the first // tick over a populated history announces one pattern and permanently silences // every other pattern found in the same pass. That is the intent, not an // oversight — an inferred routine is not worth a second attempt at his // attention, and /routines lists all of them. So the cooldown does not drain a // backlog. It only spaces announcements of genuinely new pairs discovered on // later ticks. If it should ever become "one per day until each is mentioned", // that needs a queue rather than this counter. // // Cooldown gets its default here as well as in applyDefaults. That is // deliberate: a tickLoop assembled directly in a test never goes through Load, // and an unspaced announcer is not what those tests mean to exercise. // // The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine // — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so // an inferred routine cannot arrive worded as something Maven never observed. func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool { if !t.proposalCfg.AnnounceProposals() { return false } cooldown := time.Duration(t.proposalCfg.Cooldown) if cooldown <= 0 { cooldown = config.DefaultProposalCooldown } if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown { return false } rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1} if !loop.Gate(state, rule) { return false } body := pattern.PhraseRoutine(r) pn := delivery.PhrasedNudge{ Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state}, Body: body, Summary: body, } sent, err := t.dispatcher.DispatchNudge(ctx, pn, now) if err != nil { log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err) return false } if len(sent) == 0 { return false // routing dropped it — /routines still has it. } t.lastProposalAt = now return true } // routinesFromConfig maps the config's routine blocks to the engine type. // Validation (cron parses, name/body present, severity defaulted) already ran // in config.Load, so this is a pure field copy. func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine { if len(rc) == 0 { return nil } out := make([]routine.Routine, len(rc)) for i, r := range rc { out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity} } return out } // fireRoutines dispatches the routines whose cron schedule crossed since their // last fire. Each is delivered as a nudge through the normal routing table // (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it // can't collide with a care rule in the feedback autotuner. A dispatch failure // logs and continues — one bad send must not skip the rest, and routine.Due has // already advanced the last-fire time so a transient failure drops that fire // rather than replaying it every tick (a routine is clockwork, not an alarm — // no repeat-til-ack). func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) { for _, r := range routine.Due(t.routines, t.routineLast, now) { pn := delivery.PhrasedNudge{ Candidate: loop.Candidate{ Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)}, Severity: loop.Severity(r.Severity), State: state, }, Body: r.Body, Summary: r.Body, } if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil { log.Printf("tick: dispatch routine %s: %v", r.Name, err) } } } // fireAcceptedRoutines nudges about the routines the user accepted, once per // interval (Vikunja #366). Accepting used to create a single reminder, so a // non-weekly routine fired once and went quiet forever; the schedule lives in // the proposed_routines row now and the loop re-reads it every tick. // // A routine is a care-class nudge and goes through the restraint gate like any // other: quiet hours, away presence and snooze all suppress it. Reminders bypass // that gate; routines must not. A suppressed nudge is NOT marked fired, so it // goes out on the next tick that the gate allows — one nudge, held, not dropped // and not repeated. // // The body is literal text built from the detected action and object, not // LLM-phrased, so a routine can't hallucinate. It nudges; it never acts. func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) { rows, err := t.store.ListAcceptedRoutines(ctx) if err != nil { log.Printf("tick: list accepted routines: %v", err) return } accepted := make([]routine.Accepted, 0, len(rows)) for _, r := range rows { if r.AcceptedTs == nil { continue // accepted before the schedule column existed — no clock to start from. } accepted = append(accepted, routine.Accepted{ ID: r.ID, Name: r.Action + " " + r.Object, IntervalDays: r.IntervalDays, Accepted: *r.AcceptedTs, LastFired: r.LastFiredTs, }) } for _, a := range routine.DueAccepted(accepted, now) { rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1} if !loop.Gate(state, rule) { continue } body := "пора: " + a.Name pn := delivery.PhrasedNudge{ Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state}, Body: body, Summary: body, } sent, err := t.dispatcher.DispatchNudge(ctx, pn, now) if err != nil { log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err) continue } if len(sent) == 0 { continue // routing dropped it — leave it due. } if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil { log.Printf("tick: mark routine %d fired: %v", a.ID, err) } } }