maven: scheduled routines + persistent long-term memory
Two additive proactive/recall features. Routines (internal/routine): a third proactive class beside reminders (user-stated) and care rules (world-state) — operator-declared clockwork. config.routines[] (cron + literal RU body + severity) fire through the normal dispatcher on schedule. Bodies are literal, not LLM-phrased (can't hallucinate); rule name routine:<name> keeps them out of the care autotuner; a cold-start guard seeds on first sight so a restart never replays a missed schedule. Pure routine.Due + config validation, unit- tested; the tick driver holds the last-fired map and calls fireRoutines. Persistent memory (internal/store/memory.go): store.MemoryStore backs the memory.Store interface with the SAME encrypted sqlite db — survives restarts and recall text inherits at-rest encryption (no plaintext sidecar). float32-blob vectors, brute-force cosine (ANN is a later swap behind the interface), upsert-by-id. The daemon wires st.VectorMemory() into wireVoice; the in-memory impl stays the test/no-store floor. Closes the "in-memory only, lost on restart" gap (PROGRESS #8). Gate green: gofmt/vet clean, -race across routine/config/store/mavend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2PNdwDj2Gt8YW294J7oSc
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Package routine is maven's scheduled-behavior engine: things she does on a
|
||||
// cron schedule, independent of any reactive request or care predicate.
|
||||
//
|
||||
// A Routine is the third proactive class, distinct from the two that already
|
||||
// exist:
|
||||
//
|
||||
// - a Reminder (internal/store) is a USER-stated one-off/recurring intent —
|
||||
// "напомни завтра позвонить маме". It exists because the user asked.
|
||||
// - a care Rule (internal/loop) fires on WORLD-STATE predicates — water/meal/
|
||||
// break/service_down. It exists because a snapshot crossed a threshold.
|
||||
// - a Routine is OPERATOR-declared clockwork — an 08:00 morning briefing, a
|
||||
// 22:00 wind-down. It exists purely because the clock said so.
|
||||
//
|
||||
// This package is pure: no store, no clock of its own, no I/O. The daemon's tick
|
||||
// driver owns the impurity (it holds the last-fired map and calls Due each tick),
|
||||
// exactly as it does for the loop package. That keeps the schedule logic here
|
||||
// unit-testable without time side effects.
|
||||
package routine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// Routine — one scheduled behavior. Cron is a standard 5-field expression
|
||||
// (minute hour dom month dow). Body is the RU text delivered verbatim through
|
||||
// the normal dispatcher (routines are NOT LLM-phrased: the operator writes the
|
||||
// line, so it's deterministic and can't hallucinate). Severity (1-4) drives
|
||||
// routing the same way a nudge's does — care-class (≤2) is suppressed by quiet
|
||||
// hours and drops when away; ops-class (3-4) reaches away channels.
|
||||
type Routine struct {
|
||||
Name string
|
||||
Cron string
|
||||
Body string
|
||||
Severity int
|
||||
}
|
||||
|
||||
// Validate reports the first structural problem with a routine set: a missing
|
||||
// name/cron/body or an unparseable cron expression. Called at config load so a
|
||||
// typo surfaces at startup, not as a silently-never-firing routine at runtime.
|
||||
func Validate(routines []Routine) error {
|
||||
for _, r := range routines {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("routine: name is required")
|
||||
}
|
||||
if r.Body == "" {
|
||||
return fmt.Errorf("routine %q: body is required", r.Name)
|
||||
}
|
||||
if _, err := cron.ParseStandard(r.Cron); err != nil {
|
||||
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Due returns the routines whose schedule crossed since their last fire and
|
||||
// records now as the new last-fire time for each one returned. The caller owns
|
||||
// `last` (the tick driver holds it across ticks); Due mutates it in place.
|
||||
//
|
||||
// Cold-start guard: a routine absent from `last` (never seen — fresh daemon, or
|
||||
// a newly-added routine) is SEEDED to now WITHOUT firing. Without this, a daemon
|
||||
// restart at 12:00 would replay the 08:00 briefing, because Next() computed from
|
||||
// the zero time is always in the past. The cost is that a routine can't fire in
|
||||
// the same tick the daemon booted — an acceptable trade for never replaying a
|
||||
// missed schedule on restart.
|
||||
//
|
||||
// A routine whose cron fails to parse is skipped (Validate rejects those at
|
||||
// load; this is defence in depth so a bad expression can't fire every tick).
|
||||
func Due(routines []Routine, last map[string]time.Time, now time.Time) []Routine {
|
||||
var out []Routine
|
||||
for _, r := range routines {
|
||||
sched, err := cron.ParseStandard(r.Cron)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
prev, seen := last[r.Name]
|
||||
if !seen {
|
||||
last[r.Name] = now // seed on first sight — don't fire (cold-start guard)
|
||||
continue
|
||||
}
|
||||
if next := sched.Next(prev); !next.After(now) {
|
||||
last[r.Name] = now
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user