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
+2 -2
View File
@@ -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{
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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)