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:
kami
2026-07-06 12:37:28 +04:00
parent 186bbb960b
commit 59a4e06615
11 changed files with 590 additions and 13 deletions
+89
View File
@@ -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
}
+88
View File
@@ -0,0 +1,88 @@
package routine
import (
"testing"
"time"
)
func TestValidate(t *testing.T) {
ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}}
if err := Validate(ok); err != nil {
t.Fatalf("valid routine rejected: %v", err)
}
cases := []struct {
name string
r Routine
}{
{"missing name", Routine{Cron: "0 8 * * *", Body: "x"}},
{"missing body", Routine{Name: "m", Cron: "0 8 * * *"}},
{"bad cron", Routine{Name: "m", Cron: "not a cron", Body: "x"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := Validate([]Routine{c.r}); err == nil {
t.Error("expected an error, got nil")
}
})
}
}
func TestDue(t *testing.T) {
// a routine that fires at 08:00 every day.
rs := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро", Severity: 1}}
t.Run("first sight seeds without firing (cold-start guard)", func(t *testing.T) {
last := map[string]time.Time{}
now := time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC) // exactly on schedule
got := Due(rs, last, now)
if len(got) != 0 {
t.Errorf("routine fired on first sight: %v", got)
}
if _, seen := last["morning"]; !seen {
t.Error("first sight did not seed the last-fired map")
}
})
t.Run("fires once the schedule crosses", func(t *testing.T) {
last := map[string]time.Time{"morning": time.Date(2026, 7, 6, 7, 30, 0, 0, time.UTC)}
now := time.Date(2026, 7, 6, 8, 0, 30, 0, time.UTC) // just past 08:00
got := Due(rs, last, now)
if len(got) != 1 || got[0].Name != "morning" {
t.Fatalf("expected morning to fire, got %v", got)
}
if !last["morning"].Equal(now) {
t.Errorf("last-fired not advanced to now: %v", last["morning"])
}
})
t.Run("does not refire before the next crossing", func(t *testing.T) {
last := map[string]time.Time{"morning": time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC)}
now := time.Date(2026, 7, 6, 8, 5, 0, 0, time.UTC) // same morning, later
if got := Due(rs, last, now); len(got) != 0 {
t.Errorf("routine refired within the same window: %v", got)
}
})
t.Run("missed schedule on restart fires at most once, not per-tick", func(t *testing.T) {
// booted at 07:00, seeded then; next tick is 09:00 (08:00 already passed).
last := map[string]time.Time{"morning": time.Date(2026, 7, 6, 7, 0, 0, 0, time.UTC)}
now := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
if got := Due(rs, last, now); len(got) != 1 {
t.Fatalf("missed 08:00 should fire once at 09:00, got %v", got)
}
// immediately after, it must not fire again.
if got := Due(rs, last, now.Add(time.Minute)); len(got) != 0 {
t.Errorf("routine fired twice for one missed schedule: %v", got)
}
})
t.Run("bad cron is skipped, not fired every tick", func(t *testing.T) {
bad := []Routine{{Name: "broken", Cron: "nonsense", Body: "x"}}
last := map[string]time.Time{"broken": time.Date(2026, 7, 6, 7, 0, 0, 0, time.UTC)}
now := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
if got := Due(bad, last, now); len(got) != 0 {
t.Errorf("unparseable cron fired: %v", got)
}
})
}