aee20a6abc
llama-server is started without -np, so it serves one request at a time and everything else queues. Mail extraction is allowed two minutes on a Thinking 1.7B, and the reader hands core up to 25 messages back to back. A turn arriving mid-extraction therefore waited for whatever was left of that budget: the router timed out into the classifier cascade and its 36.8% floor, and the phraser, which has no floor, simply waited. Memory evaluation had the same shape with a five minute budget. llm.Gate is the bound. Foreground requests never wait. Background requests run one at a time and yield while a foreground request is in flight, plus a quiet window after it that covers the gap between the router call and the phraser call of one turn. Clients get their priority from llmClientFor or llmBackgroundClientFor, so which side a caller is on is decided at wiring time. It gates only what goes through those clients, which the comment on Gate says. mail intake: the extraction timeout no longer wraps the capture writes. A model answering at 119 seconds of a 120 second budget left the first CaptureTask one second and the third none, so candidates the model had already produced were dropped with a deadline error. The mailbox name is validated before it becomes provenance, since "email:" is not a source and neither is an arbitrary string posted at the socket. The enable log prints the normalised candidate bound rather than the configured one, which said "max 0" and then wrote three. Found in review of #64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
90 lines
3.2 KiB
Go
90 lines
3.2 KiB
Go
// 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
// A generous per-request timeout: this is a long prompt to a Thinking model
|
|
// and nobody is waiting on the answer.
|
|
// Background: nobody is waiting on an observation, and it must not sit in
|
|
// front of a voice turn on the single llama-server slot.
|
|
client := llmBackgroundClientFor(lp, 5*time.Minute)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|