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:
+2
-2
@@ -128,7 +128,7 @@ func run(args []string) error {
|
||||
defer phr.Close()
|
||||
|
||||
// ----- voice: reactive audio path (TCP listener + stt/router/tts) -----
|
||||
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr)
|
||||
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory())
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest)
|
||||
tl := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines))
|
||||
|
||||
// ----- IPC boundary (core ↔ modules) -----
|
||||
coreAPI := &daemonAPI{
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,12 @@ type tickLoop struct {
|
||||
// immediately (legacy behaviour).
|
||||
digestCfg *config.DigestConfig
|
||||
|
||||
// routines — operator-declared scheduled behaviors. fired through the
|
||||
// dispatcher when their cron crosses. routineLast tracks the per-routine
|
||||
// last-fire time across ticks (the driver owns it; routine.Due mutates it).
|
||||
routines []routine.Routine
|
||||
routineLast map[string]time.Time
|
||||
|
||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||
digestQ []QueuedNudge
|
||||
@@ -76,6 +83,7 @@ func newTickLoop(
|
||||
rules []loop.Rule,
|
||||
tickInterval, repeatInterval, autotuneInterval time.Duration,
|
||||
digestCfg *config.DigestConfig,
|
||||
routines []routine.Routine,
|
||||
) *tickLoop {
|
||||
return &tickLoop{
|
||||
store: st,
|
||||
@@ -88,6 +96,8 @@ func newTickLoop(
|
||||
autotuneInterval: autotuneInterval,
|
||||
digestCfg: digestCfg,
|
||||
digestQ: nil,
|
||||
routines: routines,
|
||||
routineLast: make(map[string]time.Time),
|
||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||
}
|
||||
}
|
||||
@@ -160,6 +170,12 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// (with dedup) avoids re-queueing the same rule after a flush.
|
||||
t.maybeFlush(ctx, now, state)
|
||||
|
||||
// routines: operator-declared scheduled behaviors. fire the ones whose cron
|
||||
// crossed since last fire, delivered through the normal routing (voice when
|
||||
// present, away channels otherwise). bodies are literal operator text — not
|
||||
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
|
||||
t.fireRoutines(ctx, now, state)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
// re-gathers and re-attempts.
|
||||
@@ -307,6 +323,45 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
|
||||
+49
-1
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,54 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg)
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil)
|
||||
}
|
||||
|
||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
// A routine scheduled for 12:00 daily. On the first tick it seeds (cold-start
|
||||
// guard — no fire); on a tick past the next 12:00 crossing it fires through
|
||||
// the dispatcher with its literal body and severity.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow() // 2026-06-30 12:00 UTC
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
sink := &fakeSink{}
|
||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs)
|
||||
|
||||
// first tick: seeds, does not fire the routine.
|
||||
tl.tick(ctx, now)
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == "routine:morning" {
|
||||
t.Fatal("routine fired on the seeding tick (cold-start guard failed)")
|
||||
}
|
||||
}
|
||||
|
||||
// next day, just past 12:00 — the schedule crossed.
|
||||
next := now.Add(24 * time.Hour).Add(time.Minute)
|
||||
markPresent(t, st, ctx, next)
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, next)
|
||||
|
||||
var got *delivery.Sendable
|
||||
for i := range sink.sends {
|
||||
if sink.sends[i].RuleName == "routine:morning" {
|
||||
got = &sink.sends[i]
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("routine did not fire after its schedule crossed; sends=%+v", sink.sends)
|
||||
}
|
||||
if got.Body != "полдень, время воды" {
|
||||
t.Errorf("routine body = %q, want the literal config body", got.Body)
|
||||
}
|
||||
if got.Channel != delivery.ChannelVoice {
|
||||
t.Errorf("routine channel = %v, want voice (sev1 present)", got.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
// refNow — fixed tick time so presence decay + since durations are deterministic.
|
||||
|
||||
+7
-3
@@ -111,7 +111,7 @@ func (w *voiceWiring) close() {
|
||||
//
|
||||
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
|
||||
// slot using w.sessions (the caller does that — see main.go).
|
||||
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*voiceWiring, error) {
|
||||
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, memStore memory.Store) (*voiceWiring, error) {
|
||||
if cfg.Voice == nil || !cfg.Voice.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -203,8 +203,12 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*v
|
||||
// ----- voice sink (proactive nudges: dispatcher → voicesink → tts → push to client) -----
|
||||
w.voiceSink = voicesink.New(synthesizer, sessions)
|
||||
|
||||
// ----- memory (long-term vector storage, in-memory for now) -----
|
||||
memStore := memory.NewInMemoryStore()
|
||||
// ----- memory (long-term vector storage) -----
|
||||
// Persistent (store-backed, survives restarts) when the daemon passes one;
|
||||
// falls back to the in-memory floor otherwise (tests / no-store paths).
|
||||
if memStore == nil {
|
||||
memStore = memory.NewInMemoryStore()
|
||||
}
|
||||
|
||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||
dialogueSessions := dialogue.NewSessionStore(2 * time.Minute)
|
||||
|
||||
Reference in New Issue
Block a user