// mavend/memoryeval.go — the driver for background memory evaluation // (Vikunja #248). The evaluator itself is pure-ish and lives in // internal/memeval; this is the one impure part: a ticker, the store, and the // resident model's base URL. // // It is its own goroutine and NOT a step on the main tick, deliberately. The // tick runs every 60s and has a delivery deadline behind it; an evaluation is // a multi-second LLM round-trip on the same llama-server that answers voice // turns, and it happens hourly at most. Bolting it onto the tick would make // every hour's tick the slow one for no benefit. package main import ( "context" "log" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/memeval" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" ) // memoryEvalTimeout — the per-request deadline on one evaluation. // // It used to be five minutes, on the grounds that nobody waits for the answer. // Nobody waits for the evaluation, but there is ONE resident model behind one // llama-server, so a voice turn that arrives mid-evaluation waits behind it: // five minutes of evaluation is five minutes of a mute assistant. Sixty seconds // is long enough for a Thinking model on this prompt and short enough that the // worst collision is one turn answered late rather than a turn abandoned. An // evaluation cut off here costs nothing: it is retried at the next interval. const memoryEvalTimeout = 60 * time.Second // memoryEvalWorker — ticker + evaluator. type memoryEvalWorker struct { eval *memeval.Evaluator interval time.Duration } // newMemoryEvalWorker wires the evaluation loop, or returns nil when it should // not run at all. nil is the normal case and every caller must handle it: // // - no memory_eval config block ⇒ off (a capability is off unless configured); // - no LLM phraser ⇒ nothing to evaluate with. There is no template fallback // here on purpose: a "memory evaluation" assembled from string templates // would be a fixed sentence pretending to be an observation. func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Config) *memoryEvalWorker { if cfg.MemoryEval == nil { return nil } lp, ok := phr.(*phraser.LLMPhraser) if !ok { log.Printf("memory eval: configured but no llama-server phraser — evaluation disabled") return nil } interval := time.Duration(cfg.MemoryEval.Interval) if interval <= 0 { interval = config.DefaultMemoryEvalInterval } client := llmClientFor(lp, memoryEvalTimeout) ev := memeval.NewEvaluator(st, st, client, memeval.Config{ MaxItems: cfg.MemoryEval.MaxItems, MinConfidence: cfg.MemoryEval.MinConfidence, ContextBlock: contextBlockFn(cfg, time.Now), }) log.Printf("memory eval: enabled, every %s", interval) return &memoryEvalWorker{eval: ev, interval: interval} } // run evaluates every interval until ctx is canceled. // // The first evaluation waits a full interval rather than firing at startup, the // opposite of the tick loop's cold-start behaviour. A tick that fires late is a // nudge that arrives late; an evaluation that fires late is nothing at all, and // the alternative is a heavy LLM call competing with startup — including with // the first voice turn after a restart. func (w *memoryEvalWorker) run(ctx context.Context) { ticker := time.NewTicker(w.interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case now := <-ticker.C: obs, err := w.eval.Evaluate(ctx, now) if err != nil { log.Printf("memory eval: %v", err) continue } for _, o := range obs { log.Printf("memory eval: noted (%.2f, %s): %s", o.Conf, o.Action, o.Text) } } } }