fix zombie leak, add quiet-hours toggle, improve query reply, configurable router threshold, JS dashboard

This commit is contained in:
kami
2026-07-03 00:42:35 +02:00
parent 612583d59a
commit e00cb07658
26 changed files with 3652 additions and 15 deletions
+212
View File
@@ -0,0 +1,212 @@
// Package main is mavend — maven's daemon.
//
// "core = the only key-holder": one process holds the unlocked store + the
// trigger loop; modules are separate processes, key-free, fail-independent.
// the daemon wires Store → Gatherer → Tick → phraser → delivery, runs the 60s
// ticker, owns the cold-start unlock dance, and exposes the CoreAPI boundary
// over a unix socket for modules (router/delivery/poller/...) to call.
//
// Floor (this file): pluggable seams wired with the deterministic Stubs.
// - phraser Stub (no LLM)
// - voice sink wired via wireVoice: embedder/classifier seeded with ~10
// examples across 5 intents; stt + tts stubs in-process by default,
// remote module sockets when configured; TCP listener on voice.bind.
// The voice sink (voicesink.Sink via Sessions) is wired into the
// dispatcher — the reactive path (push-to-talk) AND proactive nudges
// (care-when-present, sev3/sev4 present) both route through the same
// stt→router→tts→client pipeline.
// - auth FloorEnrollment + nil Session — cold-start unlock assumed: today
// the store opens plain sqlite (sqlcipher deferred). the daemon runs
// "unlocked" — the locked-until-asserted dance lands with the Session
// verifier + ask-password transport (open spec item).
//
// Everything wired here is swappable at the construction seam — every module
// behind an interface — without changing the loop. Wire a real phraser, voice
// sink, or session verifier, and the daemon's main loop is unchanged.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/delivery/ntfysink"
"github.com/kami/maven/internal/delivery/telegramsink"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "mavend:", err)
os.Exit(1)
}
}
func run(args []string) error {
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
flag.CommandLine.Parse(args)
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
// ----- store (the unlocked handle; core = the only key-holder) -----
st, err := store.Open(ctx, cfg.DBPath)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
// ----- loop: rules + gatherer -----
rules := loop.DefaultRules()
gatherer := loop.NewGatherer(st, rules)
// ----- phraser (LLM-backed when configured, Stub floor otherwise) -----
var phr phraser.Phraser = phraser.NewStub()
if cfg.Phraser != nil {
pc := phraser.Config{
ModelPath: cfg.Phraser.ModelPath,
BinPath: cfg.Phraser.BinPath,
Listen: cfg.Phraser.Listen,
NGpuLayers: cfg.Phraser.NGpuLayers,
NCtx: cfg.Phraser.NCtx,
Timeout: time.Duration(cfg.Phraser.Timeout),
}
if pc.BinPath == "" {
pc.BinPath = "llama-server"
}
if pc.Listen == "" {
pc.Listen = "127.0.0.1:0"
}
if pc.NCtx <= 0 {
pc.NCtx = 2048
}
if pc.Timeout <= 0 {
pc.Timeout = 30 * time.Second
}
var err error
phr, err = phraser.NewLLMPhraser(ctx, pc)
if err != nil {
return fmt.Errorf("phraser: %w", err)
}
}
defer phr.Close()
// ----- voice: reactive audio path (TCP listener + stt/router/tts) -----
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st))
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
defer voiceW.close()
// ----- delivery: sinks + dispatcher -----
var ntfy delivery.Sink
if cfg.Ntfy != nil {
s, err := ntfysink.New(*cfg.Ntfy)
if err != nil {
return fmt.Errorf("wire ntfy sink: %w", err)
}
ntfy = s
}
var telegram delivery.Sink
if cfg.Telegram != nil {
s, err := telegramsink.New(*cfg.Telegram)
if err != nil {
return fmt.Errorf("wire telegram sink: %w", err)
}
telegram = s
}
// Voice sink: nil when voice is not enabled — the dispatcher's nil-sink
// path skips ChannelVoice silently, just like the pre-voice floor).
var voiceSink delivery.Sink
if voiceW != nil {
voiceSink = voiceW.voiceSink
}
dispatcher := delivery.NewDispatcher(delivery.Config{
Ntfy: ntfy,
Telegram: telegram,
Voice: voiceSink,
// AckTracker nil ⇒ repeat-til-ack disabled in the dispatcher. We
// drive repeats from store.UnackedTelegramRules + Dispatcher.RepeatUnacked
// below, which uses the nudges table's outcome=pending row itself as
// the ack-or-not state — the production ack source. The AckTracker
// interface stays reserved for an in-memory cache if the daemon ever
// wants to drive repeats without the SQL hit; the table IS the truth.
Nudges: st, // *store.Store satisfies delivery.NudgeRecorder
Reminders: st, // *store.Store satisfies delivery.ReminderCompleter
})
// ----- the proactive loop driver (60s ticker, lives HERE per spec) -----
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
loop := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval)
// ----- IPC boundary (core ↔ modules) -----
coreAPI := ipc.NewStoreAPI(st)
srv, err := ipc.Listen(cfg.SocketPath, coreAPI)
if err != nil {
return fmt.Errorf("ipc listen: %w", err)
}
// auth floor: any same-uid caller is fully trusted (FloorEnrollment +
// FloorSession — L3, step-up satisfied). The cold-start unlock dance and a
// real passkey Session are the open spec items; today the daemon runs
// unlocked — plain sqlite, sqlcipher deferred. FloorSession keeps the floor
// consistent so the authed mavweb /tools page can EnableTool (AuthStepUp)
// against the local socket; the passkey verifier swaps FloorSession later.
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: auth.FloorSession{}}).Check
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("ipc serve: %v", err)
}
}()
log.Printf("mavend: ipc listening on %s", srv.Path())
if voiceW != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("voice serve: %v", err)
}
}()
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
}
wg.Add(1)
go func() {
defer wg.Done()
loop.run(ctx)
}()
<-ctx.Done()
log.Printf("mavend: shutdown signal received")
if err := srv.Close(); err != nil {
log.Printf("ipc close: %v", err)
}
wg.Wait()
log.Printf("mavend: bye")
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"time"
"github.com/kami/maven/internal/store"
)
func main() {
ctx := context.Background()
st, err := store.Open(ctx, "/tmp/maven-test.db")
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
os.Exit(1)
}
defer st.Close()
now := time.Now()
_, err = st.WriteFact(ctx, now.Add(-4*time.Hour), store.KindSelf, "water", "1", "tap:voice", 1.0, sql.NullInt64{})
if err != nil {
fmt.Fprintln(os.Stderr, "write water fact:", err)
os.Exit(1)
}
fmt.Println("seeded water fact 4h ago")
_, err = st.WriteFact(ctx, now.Add(-time.Minute), store.KindSelf, "desk_active", "1", "presence", 1.0, sql.NullInt64{})
if err != nil {
fmt.Fprintln(os.Stderr, "write desk_active fact:", err)
os.Exit(1)
}
fmt.Println("seeded desk_active (presence) 1m ago")
_, err = st.SetValue(ctx, store.KindConfig, "quiet_hours", "config", "false", now)
if err != nil {
fmt.Fprintln(os.Stderr, "write quiet_hours:", err)
os.Exit(1)
}
fmt.Println("seeded quiet_hours=false")
}
+249
View File
@@ -0,0 +1,249 @@
// mavend/tick.go — the proactive loop driver.
//
// Per spec the 60s schedule loop (`for { tick; sleep }`) lives in the daemon
// main, NOT in `internal/loop/` — that keeps the loop package pure + unit-
// testable without time side effects. the driver here is the ONE impure
// orchestrator: it gathers state under the store lock, runs the pure Tick,
// phrases the candidate, dispatches it, then handles reminders + sev4 repeats
// and runs the feedback auto-tuner on its own slow cadence.
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
// tickLoop — the impure driver. holds everything wired at daemon construction
// that the per-tick path needs. the rules slice is read-only here; the gatherer
// already captured it, but we keep it for a possible future re-seed path.
type tickLoop struct {
store *store.Store
gatherer *loop.Gatherer
dispatcher *delivery.Dispatcher
phraser phraser.Phraser
rules []loop.Rule
tickInterval time.Duration
repeatInterval time.Duration
autotuneInterval time.Duration // 0 ⇒ autotune disabled (gatherer falls back to static Base)
// lastPhrase caches the phraser output per rule so the sev4 repeat path
// can re-send roughly what the user was first alerted with (an alarm
// that re-phrases differently every 5m is hostile; the same terse body
// IS the insistence signal). keyed by rule name. nil phrase for a rule
// = no successful initial dispatch yet (cold-start edge — fall back to
// a generic body).
mu sync.Mutex
lastPhrase map[string]delivery.PhrasedNudge
}
func newTickLoop(
st *store.Store,
g *loop.Gatherer,
d *delivery.Dispatcher,
p phraser.Phraser,
rules []loop.Rule,
tickInterval, repeatInterval, autotuneInterval time.Duration,
) *tickLoop {
return &tickLoop{
store: st,
gatherer: g,
dispatcher: d,
phraser: p,
rules: rules,
tickInterval: tickInterval,
repeatInterval: repeatInterval,
autotuneInterval: autotuneInterval,
lastPhrase: make(map[string]delivery.PhrasedNudge),
}
}
// run drives the loop until ctx is canceled. one tick per tickInterval;
// the first tick fires immediately so a freshly-started daemon doesn't sit
// idle for 60s before its first evaluation (cold-start responsiveness). the
// feedback auto-tuner runs on its own slower ticker (autotuneInterval) so it
// doesn't write a feedback fact every tick — append-only facts would churn.
func (t *tickLoop) run(ctx context.Context) {
t.tick(ctx, time.Now())
ticker := time.NewTicker(t.tickInterval)
defer ticker.Stop()
var autotune *time.Ticker
var autotuneC <-chan time.Time
if t.autotuneInterval > 0 {
autotune = time.NewTicker(t.autotuneInterval)
defer autotune.Stop()
autotuneC = autotune.C
}
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
t.tick(ctx, now)
case <-autotuneC:
t.tune(ctx)
}
}
}
// tick — one pass of the proactive loop. gathers, decides, phrases, delivers.
// errors at any sub-step are logged and the tick continues / aborts as the
// layer warrants: a gather failure aborts (no consistent snapshot ⇒ no
// decisions); a phrase/dispatch failure logs the failure and continues so a
// transient delivery fault doesn't kill the whole loop.
func (t *tickLoop) tick(ctx context.Context, now time.Time) {
state, due, err := t.gatherer.GatherState(ctx, now)
if err != nil {
log.Printf("tick: gather: %v", err)
return
}
// proactive: at most one candidate, max severity.
if cand := loop.Tick(state, t.rules); cand != nil {
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
} else {
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch nudge %s: %v", cand.Rule.Name, err)
}
}
}
// 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.
for _, d := range loop.RemindDecisions(state, due) {
pr, err := t.phraser.PhraseReminder(ctx, d)
if err != nil {
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
continue
}
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
}
}
// sev4-away repeats: re-send un-acked telegram nudges per repeatInterval.
// the source of ack truth IS the nudges table (outcome=pending ⇒ not
// acked); store.UnackedTelegramRules surfaces the keys. body/summary come
// from the cached phrase from the initial dispatch — see lastPhrase notes.
// if not cached (cold-start mid-alarm), fall back to a terse generic body.
keys, err := t.store.UnackedTelegramRules(ctx)
if err != nil {
log.Printf("tick: unacked telegram rules: %v", err)
return
}
if len(keys) == 0 {
return
}
for _, key := range keys {
body, summary := t.repeatPhrase(key)
if _, err := t.dispatcher.RepeatUnacked(ctx, []string{key}, now, t.repeatInterval, body, summary); err != nil {
log.Printf("tick: repeat telegram %s: %v", key, err)
}
}
}
// cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat
// path. writing under a mutex; the repeat path reads under the same. the
// cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a
// concern at this scale.
func (t *tickLoop) cachePhrase(pn delivery.PhrasedNudge) {
t.mu.Lock()
t.lastPhrase[pn.Candidate.Rule.Name] = pn
t.mu.Unlock()
}
func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
t.mu.Lock()
pn, ok := t.lastPhrase[rule]
t.mu.Unlock()
if !ok || pn.Summary == "" {
// cold-start mid-alarm: no cached phrase. a deliberately terse generic
// body — the alarm IS the insistence; the wording repeats, the ring
// is what changes. the LLM phraser impl will refresh this on its next
// tick when the rule re-fires through Tick.
return fmt.Sprintf("maven: %s still active", rule), rule
}
return pn.Body, pn.Summary
}
// 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:
// 1. read store.RecentOutcomes for the last TuneSampleN resolved outcomes.
// 2. if there's not enough signal (TuneMinOutcomes), leave Base alone.
// 3. compute the tuned cooldown with loop.TuneCooldown (pure).
// 4. read the currently-persisted feedback fact; if the tuned value equals
// it, skip the write (RecentOutcomes is itself steady ⇒ no churn).
// 5. else write a `facts (kind=config, source=feedback, key=cooldown:<rule>)`
// row. the Gatherer reads it next tick.
//
// Error at any step logs + continues to the next rule — a transient store
// fault on one rule must not abort tuning for the rest.
func (t *tickLoop) tune(ctx context.Context) {
now := time.Now()
for _, r := range t.rules {
outcomes, err := t.store.RecentOutcomes(ctx, r.Name, loop.TuneSampleN)
if err != nil {
log.Printf("tune: outcomes %s: %v", r.Name, err)
continue
}
if len(outcomes) < loop.TuneMinOutcomes {
continue // sparse — no signal yet, don't whipsaw on first sight.
}
tuned := loop.TuneCooldown(r, outcomes)
// the currently-persisted tuned base, if any. equal ⇒ skip the write
// (RecentOutcomes is monotone-steady between resolved outcomes).
if cur, ok := t.currentTunedBase(ctx, r); ok && cur == tuned {
continue
}
if _, err := t.store.SetValue(
ctx, store.KindConfig, loop.FeedbackKey(r), loop.FeedbackSource,
tuned, now,
); err != nil {
log.Printf("tune: persist %s: %v", r.Name, err)
continue
}
log.Printf("tune: %s cooldown -> %v", r.Name, tuned)
}
}
// currentTunedBase — read the persisted feedback cooldown fact back into a
// duration. (dur, false) when no feedback fact exists yet OR it's malformed;
// the caller treats that as "differ from anything we'd write — write."
func (t *tickLoop) currentTunedBase(ctx context.Context, r loop.Rule) (time.Duration, bool) {
f, err := t.store.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
return 0, false
}
return loop.ParseCooldownFact(f)
}
// defaultConfigPath — the config file path the daemon loads if -config wasn't
// passed. XDG_CONFIG_HOME/maven/mavend.json, falling back to ~/.config/maven.
func defaultConfigPath() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "maven", "mavend.json")
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "mavend.json"
}
return filepath.Join(home, ".config", "maven", "mavend.json")
}
+288
View File
@@ -0,0 +1,288 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
// fakeSink — captures every Send for assertion. implements delivery.Sink.
type fakeSink struct {
sends []delivery.Sendable
}
func (f *fakeSink) Send(_ context.Context, s delivery.Sendable) error {
f.sends = append(f.sends, s)
return nil
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "mavend_test.db")
st, err := store.Open(context.Background(), path)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
t.Helper()
rules := loop.DefaultRules()
g := loop.NewGatherer(st, rules)
d := delivery.NewDispatcher(delivery.Config{
Voice: sink,
Ntfy: sink,
Telegram: sink,
Nudges: st,
Reminders: st,
})
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0)
}
// refNow — fixed tick time so presence decay + since durations are deterministic.
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
// markPresent seeds desk_active + page_heartbeat with fresh ts so presence
// resolves to Present for the given tick time (cold-start is away; ENTER at
// 0.55 — a fresh desk_active alone gives 0.90, well over).
func markPresent(t *testing.T, st *store.Store, ctx context.Context, now time.Time) {
t.Helper()
for _, key := range []string{"desk_active", "page_heartbeat"} {
if _, err := st.SetValue(ctx, store.KindSelf, key, "tap:desk", map[string]bool{key: true}, now); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
}
func TestTickColdStoreSendsNothing(t *testing.T) {
// The "shuts up when uncertain" floor: no facts ⇒ every rule's
// InertWhenNoData keys are missing ⇒ gate skips them. the loop's silence
// is the default outcome of a tick on an empty store.
st := newTestStore(t)
ctx := context.Background()
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink)
tl.tick(ctx, refNow())
if len(sink.sends) != 0 {
t.Fatalf("cold-store tick sent %d; want 0 (shuts up when no data)", len(sink.sends))
}
}
func TestTickWaterFiresWhenDueAndPresent(t *testing.T) {
// water fact 4h ago ⇒ since(water)=4h ≥ 3h ⇒ predicate true. presence
// present ⇒ sev1 care gate holds (no quiet/cal/cooldown). routing for
// sev1 present is [voice] — one send captured.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink)
tl.tick(ctx, now)
if len(sink.sends) != 1 {
t.Fatalf("tick sends = %d, want 1 (water, voice only)", len(sink.sends))
}
if got, want := sink.sends[0].RuleName, "water"; got != want {
t.Errorf("send rule = %q, want %q", got, want)
}
if got, want := sink.sends[0].Channel, delivery.ChannelVoice; got != want {
t.Errorf("send channel = %v, want voice (sev1 present)", got)
}
if sink.sends[0].Body == "" {
t.Error("phraser Stub produced an empty body for the water nudge")
}
// one nudge row recorded with channel=voice — verify the dispatch path
// wrote through to the store (the feedback loop's only input). RecentOutcomes
// filters to resolved rows, so confirm the recorded nudge exists via LastNudge.
n, err := st.LastNudge(ctx, "water")
if err != nil {
t.Fatalf("LastNudge: %v", err)
}
if n.Channel != string(delivery.ChannelVoice) {
t.Errorf("recorded nudge channel = %q, want %q", n.Channel, delivery.ChannelVoice)
}
}
func TestTickCooldownSuppressesSecondSend(t *testing.T) {
// After a water nudge, the gate's cooldown (DefaultRules sets water
// base cooldown = 30m) suppresses the same rule on the next tick.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
_ = err
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink)
tl.tick(ctx, now) // fires
tl.tick(ctx, now.Add(time.Minute)) // still within 30m cooldown ⇒ suppressed
if len(sink.sends) != 1 {
t.Fatalf("sends after second tick = %d, want 1 (cooldown should suppress)", len(sink.sends))
}
}
func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) {
// A due reminder bypasses the gate. away (no presence probes ⇒ cold
// start away) routes the reminder to [ntfy]. the dispatcher marks the
// reminder fired only after at least one channel succeeded; verify by
// re-ticking and confirming it isn't re-dispatched (DueReminders returns
// only `status == pending AND fire_ts <= now`).
st := newTestStore(t)
ctx := context.Background()
now := refNow()
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink)
tl.tick(ctx, now)
if got, want := len(sink.sends), 1; got != want {
t.Fatalf("reminder tick sends = %d, want 1 (away → ntfy)", got)
}
if sink.sends[0].Channel != delivery.ChannelNtfy {
t.Errorf("reminder channel = %v, want ntfy (away)", sink.sends[0].Channel)
}
if sink.sends[0].Body != "stand up" {
t.Errorf("reminder body = %q, want %q (router payload text)", sink.sends[0].Body, "stand up")
}
if sink.sends[0].Kind != delivery.KindReminder {
t.Errorf("reminder kind = %v, want %v", sink.sends[0].Kind, delivery.KindReminder)
}
// re-tick: the reminder is no longer pending (marked fired) ⇒ not in
// DueReminders ⇒ the reminder path is silent.
sink.sends = nil
tl.tick(ctx, now.Add(time.Minute))
if len(sink.sends) != 0 {
t.Fatalf("second reminder tick sends = %d, want 0 (fired once)", len(sink.sends))
}
}
// TestTuneWritesFeedbackCooldownToStore — the daemon's impure tune() step end
// to end: seed a rule with ≥ TuneMinOutcomes resolved `ignored` outcomes,
// call tune(), assert it wrote a `cooldown:<rule>` (source=feedback) fact that
// the gatherer then reads back as the active cooldown base. This is the closed
// feedback loop: outcomes → tune → write → gather → gate sees the tuned base.
func TestTuneWritesFeedbackCooldownToStore(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink)
// seed enough resolved `ignored` nudge outcomes to trip the tuner (above
// TuneMinOutcomes). all-ignored ⇒ factor 1.5 ⇒ base × 1.5; clamped to Max.
r := loop.WaterRule()
for i := 0; i < loop.TuneSampleN; i++ {
id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(-time.Hour))
if err != nil {
t.Fatalf("RecordNudge %d: %v", i, err)
}
if err := st.ResolveNudge(ctx, id, store.NudgeIgnored, now); err != nil {
t.Fatalf("ResolveNudge %d: %v", i, err)
}
}
tl.tune(ctx)
// the gatherer should now use the tuned base (clamped to WaterRule.Max =
// 6h) as the cooldown base for `water`. verify via the cooldown-until
// field by seeding a water nudge + asserting cooldown = sendTs + Max.
// (we read the persisted tuned base directly rather than going via gather
// so the assertion isolates tune()'s write from the gatherer path.)
fb, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource: %v (no feedback fact written?)", err)
}
tuned, ok := loop.ParseCooldownFact(fb)
if !ok {
t.Fatalf("ParseCooldownFact: not ok (value %q)", fb.Value)
}
if tuned != 45*time.Minute {
t.Fatalf("all-ignored: base 30m × 1.5 = 45m (no — under WaterRule.Max 6h so unclamped): want 45m, got %v", tuned)
}
// idempotent: a second tune() with the same outcomes writes nothing new
// (RecentOutcomes is steady between resolves; the persisted value equals
// the computed one ⇒ skip).
fb1 := fb
tl.tune(ctx)
fb2, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource second call: %v", err)
}
if fb2.ID != fb1.ID {
t.Fatalf("tune() re-wrote identical value: fact id %d → %d (should skip when unchanged)",
fb1.ID, fb2.ID)
}
// flip the outcomes pattern to `acted`: next tune() writes a new value,
// shorter than Max. RecentOutcomes is sorted DESC ts,id, so re-seeding
// newer-acted nudges makes them dominate the older-ignored set.
for i := 0; i < loop.TuneSampleN; i++ {
id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(time.Minute+time.Duration(i)*time.Second))
if err != nil {
t.Fatalf("RecordNudge acted %d: %v", i, err)
}
if err := st.ResolveNudge(ctx, id, store.NudgeActed, now); err != nil {
t.Fatalf("ResolveNudge acted %d: %v", i, err)
}
}
tl.tune(ctx)
fb3, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource post-flip: %v", err)
}
tuned3, ok := loop.ParseCooldownFact(fb3)
if !ok {
t.Fatalf("ParseCooldownFact post-flip: not ok (value %q)", fb3.Value)
}
if tuned3 >= r.Cooldown.Max {
t.Fatalf("acted-dominated outcomes should shrink cooldown below Max: got %v (Max %v)",
tuned3, r.Cooldown.Max)
}
if tuned3 < r.Cooldown.Min {
t.Fatalf("tuned cooldown below Min envelope: got %v (Min %v) — clamp broken",
tuned3, r.Cooldown.Min)
}
// the gatherer actually reads it back: assert CooldownUntil for water is
// derived from the tuned base (not the rule's static Base) when a real
// nudge exists. seed a water nudge now and gather.
nudgeTs := now.Add(2 * time.Minute)
if _, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", nudgeTs); err != nil {
t.Fatalf("final RecordNudge: %v", err)
}
g := loop.NewGatherer(st, loop.DefaultRules())
snap, _, err := g.GatherState(ctx, now.Add(3*time.Minute))
if err != nil {
t.Fatalf("GatherState: %v", err)
}
wantUntil := nudgeTs.Add(tuned3)
if got := snap.CooldownUntil[r.Name]; got != wantUntil {
t.Fatalf("gatherer used tuned base: CooldownUntil want %v, got %v",
wantUntil, got)
}
}
+853
View File
@@ -0,0 +1,853 @@
// Package main is mavend's voice wiring + reactive handler.
//
// Two responsibilities for the audio path:
//
// 1. CONSTRUCTION: read cfg.Voice, build the stt/tts/transcribers (Stub
// in-process by default, Remote via worker socket when configured),
// the router (stage-0 grammar + HashEmbedder classifier seeded with
// floor examples — production swaps in the ONNX multilingual model
// later), the voice TCP listener, the sessions registry, the
// voicesink, and wire the voicesink into the dispatcher's Voice slot.
//
// 2. HANDLER: a concrete voice.Handler that processes PushToTalk
// requests: stt → router → action → replier → tts → reply. The
// handler is what makes the audio round-trip "live". It wires to the
// CoreAPI in-process (the daemon already has it as ipc.NewStoreAPI(st)
// for module-IPC — the reactive path uses the same CoreAPI off the
// same store; both are the "core = the only key-holder" path through
// the daemon-embedded adapter).
//
// The "actions" handled today (per spec order; some deferred):
//
// - IntentFact: WriteFact via CoreAPI. The router's Slots.Key/Value feed
// the write; Source = "tap:voice" (the voice path is a tap, value=1.0
// confidence — the user said it out loud, maven trusts the capture).
// - IntentReminder: CreateReminder via CoreAPI. The router already
// resolved relative→absolute at capture ("in 4h" → fire_ts); the
// CoreAPI stores it as-is.
// - IntentAct: the tool executor runs the matched fn against the store's
// ENABLED allowlist (internal/tool). A verb not on it is scaffolded as a
// 'proposed' tool a human enables on the authed mavweb surface (never
// voice). Destructive tools run only after a spoken confirm turn.
// - IntentNote: chroma/vector-store deferred. The handler replies
// "saved" without persisting — a stub on the way to chroma.
// - IntentQuery: RAG-over-chroma deferred. The handler replies "I'll
// look that up later" — same shape as the other deferred slots.
// - Clarify: the router's stage-3 confidence gate fired; reply "didn't
// catch that, can you rephrase?"
//
// The Replier (voice.StubReplier today) renders the reply TEXT across all
// these branches. The TTS synthesiser (tts.Stub today) renders that text
// to audio. The PushToTalkResp carries BOTH so the client can play (audio)
// AND log (text) for tests asserting the round-trip.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/delivery/voicesink"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/stt"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/worker"
)
// voiceWiring — everything the daemon needs to run the audio path. Held by
// cmd/mavend/main.go alongside the other wirings; closed on shutdown.
type voiceWiring struct {
server *voice.Server
sessions *voice.Sessions
voiceSink delivery.Sink
embedder router.Embedder
// worker clients (set when configured as Remote): closed on shutdown so
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
sttClient *worker.Client
ttsClient *worker.Client
}
// close releases the listener + worker conns. Safe to call on nil (when
// voice is not wired — wireVoice returns nil,nil).
func (w *voiceWiring) close() {
if w == nil {
return
}
if w.embedder != nil {
_ = w.embedder.Close()
}
if w.server != nil {
_ = w.server.Close()
}
if w.sttClient != nil {
_ = w.sttClient.Close()
}
if w.ttsClient != nil {
_ = w.ttsClient.Close()
}
}
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
// nil wiring + nil error when voice isn't enabled (the caller's voice sink
// stays nil; the dispatcher's ChannelVoice routing drops silently).
//
// 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) (*voiceWiring, error) {
if cfg.Voice == nil || !cfg.Voice.Enabled {
return nil, nil
}
w := &voiceWiring{}
// ----- stt (Stub in-process OR Remote via worker socket) -----
var transcriber stt.Transcriber
if cfg.Voice.Stt != nil && cfg.Voice.Stt.Socket != "" {
c := worker.Dial(cfg.Voice.Stt.Socket)
w.sttClient = c
lang := cfg.Voice.Stt.Lang
if lang == "" {
lang = cfg.Voice.Lang
}
transcriber = stt.NewRemote(c, lang)
} else {
transcriber = stt.NewStub()
}
// ----- tts (Stub in-process OR Remote) -----
var synthesizer tts.Synthesizer
if cfg.Voice.Tts != nil && cfg.Voice.Tts.Socket != "" {
c := worker.Dial(cfg.Voice.Tts.Socket)
w.ttsClient = c
lang := cfg.Voice.Tts.Lang
if lang == "" {
lang = cfg.Voice.Lang
}
synthesizer = tts.NewRemote(c, lang, cfg.Voice.Tts.Voice)
} else {
synthesizer = tts.NewStub()
}
// ----- router: embedder (ONNX when configured, floor HashEmbedder otherwise) -----
var emb router.Embedder
if cfg.Voice.Embedder != nil {
onnx, err := router.NewONNXEmbedder(
cfg.Voice.Embedder.ModelPath,
cfg.Voice.Embedder.TokenizerPath,
cfg.Voice.Embedder.LibPath,
)
if err != nil {
w.close()
return nil, fmt.Errorf("embedder: %w", err)
}
log.Printf("voice: onnx embedder loaded (%d dim)", onnx.Dim())
emb = onnx
} else {
emb = router.NewHashEmbedder(1024)
}
w.embedder = emb
// ----- tool executor (the enabled act allowlist, store-backed) -----
// Config tools are the declarative bootstrap: seed them into the store as
// enabled (editing mavend.json IS the human enable act). Ad-hoc tools are
// enabled later through the authed mavweb surface. The executor + matcher
// both read the store live, so a newly-enabled tool is runnable without a
// daemon restart.
seedTools(coreAPI, cfg.Voice.Tools)
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
matcher := tool.NewMatcher(coreAPI)
// ----- router (the cascade; floor examples seed the classifier) -----
// The act matcher's allowlist is exactly the enabled tool names — the
// router only matches acts the executor can run (one source of truth).
threshold := cfg.Voice.RouterThreshold
if threshold <= 0 {
threshold = config.DefaultRouterThreshold
}
rtr := buildRouter(emb, matcher, threshold)
// ----- sessions registry (shared with voicesink) -----
sessions := voice.NewSessions()
w.sessions = sessions
// ----- voice sink (proactive nudges: dispatcher → voicesink → tts → push to client) -----
w.voiceSink = voicesink.New(synthesizer, sessions)
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI) -----
h := &reactiveHandler{
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
replier: voice.NewStubReplier(),
now: time.Now,
}
// ----- the server (TCP listener) -----
srv := voice.NewServer(cfg.Voice.Bind, h, sessions)
if err := srv.Listen(); err != nil {
w.close()
return nil, fmt.Errorf("voice listen: %w", err)
}
w.server = srv
return w, nil
}
// reactiveHandler — voice.Handler implementation. One method: turn a
// PushToTalkReq into a reply (audio + text). The handler is concurrency-
// safe (the wired stt/tts/router/api all are); called from per-conn
// goroutines on the voice.Server.
type reactiveHandler struct {
stt stt.Transcriber
tts tts.Synthesizer
router *router.Router
embedder router.Embedder // reused for note write/query (same model as the classifier)
api ipc.CoreAPI
tools *tool.Executor
replier voice.Replier
now func() time.Time
// pending destructive-act confirmation. A destructive act replies with a
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
// the y/n answer. ponytail: single slot, single-user box — a second act
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
mu sync.Mutex
pending *pendingAct
}
// pendingAct — a destructive act awaiting a spoken confirm.
type pendingAct struct {
fn string
args []string
phrase string
expiry time.Time
}
// confirmTTL — how long a parked destructive confirm stays answerable. Short:
// a confirm is a same-breath gesture; a stale prompt shouldn't fire on an
// unrelated later "да".
const confirmTTL = 90 * time.Second
// HandlePushToTalk — the full reactive round-trip. Each step's failure
// surfaces as a short reply text + empty audio OR an error; the voice
// server translates an error into a wire RpcError. Today the handler
// prefers a canned error-reply over an error return (a user-facing "didn't
// catch that" is better than a wire error the client surfaces as
// "internal"); the only error returned is a synthesizer fault (no audio
// to ship back).
func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushToTalkReq, _ uint64) (voice.PushToTalkResp, error) {
// 1. stt — transcribe the audio.
text, _, err := h.stt.Transcribe(ctx, req.Audio)
if err != nil {
log.Printf("voice: stt error: %v", err)
return h.reply(ctx, "не получилось разобрать речь — попробуй ещё раз.", nil)
}
if text == "" {
return h.reply(ctx, "ничего не услышала — попробуй ещё раз.", nil)
}
log.Printf("voice: stt → %q", text)
// 1b. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
// get classified as some other intent.
if reply, handled := h.resolveConfirm(ctx, text); handled {
return h.reply(ctx, reply, nil)
}
// 2. router — classify the utterance.
dec, err := h.router.Route(ctx, text, h.now())
if err != nil {
// ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a
// "still warming up" rather than a wire error.
if errors.Is(err, router.ErrNoIntents) {
return h.reply(ctx, "я ещё не понимаю свободную речь — скоро научусь.", nil)
}
log.Printf("voice: router error: %v", err)
return h.reply(ctx, "не получилось разобрать команду.", nil)
}
// 3. action — execute the decision's intent. errors here surface as
// short reply text (the user wants to know the action didn't land);
// the round-trip stays alive.
replyText := h.applyAction(ctx, dec)
// 4. replier — phrase the reply across the router decision.
if replyText == "" {
replyText = h.replier.Reply(dec)
}
// 5. tts — synthesise the reply text; return to the voice server which
// ships it back on the conn.
return h.reply(ctx, replyText, nil)
}
// applyAction — executes the router's Decision. Intent-by-intent:
//
// - IntentFact: WriteFact via CoreAPI. Source = "tap:voice" (a voice
// capture is a tap; confidence 1.0).
// - IntentReminder: CreateReminder via CoreAPI.
// - IntentAct: tool-executor deferred (no-op today; the reply says so).
// - IntentNote / IntentQuery: chroma/RAG deferred (no-op; reply says so).
// - Clarify: the router's stage-3 fired; no action.
//
// Returns "" when the Replier should phrase the reply (the default path);
// returns a non-empty string when the action path wants to OVERRIDE the
// reply text (e.g. an action error the user should hear SPECIFICALLY, not
// a generic "ok"). Errors surface as a short reply text the user hears.
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
// "no note" instead of a guess. Hand-tuned for the ONNX embedder; a knob, not
// load-bearing math (same posture as the presence thresholds).
const queryMinScore = 0.55
func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) string {
if dec.Clarify {
return "" // the Replier phrases clarify
}
switch dec.Intent {
case router.IntentFact:
if !dec.Slots.HasKey {
return "не разобрала, что записать — попробуй иначе."
}
now := h.now()
req := ipc.WriteFactReq{
Ts: now,
Kind: "self",
Key: dec.Slots.Key,
Value: dec.Slots.Value,
Source: "tap:voice",
Confidence: 1.0,
}
if _, err := h.api.WriteFact(ctx, req); err != nil {
log.Printf("voice: write fact: %v", err)
return "не получилось сохранить факт."
}
return "" // replier phrases the success reply
case router.IntentReminder:
if !dec.Slots.HasTime {
return "не получилось разобрать время напоминания."
}
payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload); err != nil {
log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание."
}
return ""
case router.IntentAct:
// tool executor: run the matched fn against the enabled allowlist.
// HasFn=false ⇒ no allowlist match: scaffold a 'proposed' tool the user
// can enable on the authed surface ("earn the right to ask").
if !dec.Slots.HasFn {
return h.proposeGap(ctx, dec)
}
out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false)
if err != nil {
switch {
case errors.Is(err, tool.ErrNeedsConfirm):
// destructive: park it and ask. The next utterance answers.
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec)
}
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
if out != "" {
return "не получилось выполнить команду: " + firstLine(out)
}
return "не получилось выполнить команду."
}
if out != "" {
return "готово: " + firstLine(out)
}
return "готово."
case router.IntentSystem:
// Quiet-hours toggle — "quiet on" / "тихий режим" — writes
// a config fact the gate reads. Check before the query-only path.
if reply := h.handleQuietToggle(ctx, dec); reply != "" {
return reply
}
// System-status queries return to the Replier for phrasing.
// The handler emits the current answer inline (no DB / RAG needed).
return h.replySystem(ctx, dec)
case router.IntentNote:
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
vec, err := h.embedder.Embed(ctx, dec.Utterance)
if err != nil {
log.Printf("voice: embed note: %v", err)
return "не получилось сохранить заметку."
}
if _, err := h.api.WriteNote(ctx, h.now(), dec.Utterance, vec, "tap:voice"); err != nil {
log.Printf("voice: write note: %v", err)
return "не получилось сохранить заметку."
}
return "" // replier phrases the "saved" reply
case router.IntentQuery:
vec, err := h.embedder.Embed(ctx, dec.Utterance)
if err != nil {
log.Printf("voice: embed query: %v", err)
return "не получилось найти ответ."
}
notes, err := h.api.QueryNotes(ctx, vec, 5)
if err != nil {
log.Printf("voice: query notes: %v", err)
return "не получилось найти ответ."
}
// Confidence gate: below threshold, say "I don't know" rather than read
// back the least-unrelated note — a confident wrong recall is worse than
// a gap (spec's "not a guesser-of-truth"). Same instinct as the loop's
// since(key)==null → don't fire. Tuned for the ONNX embedder; the Hash
// floor scores lexically and may rarely clear it.
if len(notes) == 0 || notes[0].Score < queryMinScore {
return "у меня нет заметок по этому вопросу."
}
// Full RAG (phraser-composed) is deferred — this is the browse surface.
// Return a summary of the best match(es) so the user gets context, not just
// one verbatim snippet. The phraser seam in the replier will natural-language
// the results when the LLM-backed Replier swaps in.
if len(notes) == 1 {
return "ты записал: " + notes[0].Text
}
var b strings.Builder
b.WriteString("вот что нашла: ")
for i, n := range notes {
if i > 0 {
b.WriteString("; ")
}
b.WriteString(n.Text)
}
return b.String()
}
return ""
}
var ruWeekdays = []string{
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота",
}
var ruMonths = []string{
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
}
func ruPlural(n int, one, two, many string) string {
n = n % 100
if n > 10 && n < 20 {
return many
}
n = n % 10
switch n {
case 1:
return one
case 2, 3, 4:
return two
default:
return many
}
}
// handleQuietToggle — checks if the utterance toggles quiet hours.
// Writes a `quiet_hours` config fact (value "true"/"false") so the loop
// gate reads it next tick. Returns a reply text, or "" if no match.
func (h *reactiveHandler) handleQuietToggle(ctx context.Context, dec router.Decision) string {
u := strings.ToLower(dec.Utterance)
// Match: "quiet on" / "quiet off" / "тихий режим" / "не беспокоить" etc.
var on, off bool
for _, kw := range []string{"quiet on", "quiet mode", "тихий режим", "не беспокоить", "не шуми"} {
if strings.Contains(u, kw) {
on = true
break
}
}
for _, kw := range []string{"quiet off", "quiet end", "выключи тихий", "отключи тихий", "шумный режим"} {
if strings.Contains(u, kw) {
off = true
break
}
}
if !on && !off {
return ""
}
now := h.now()
val := "false"
reply := "тихий режим выключен."
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: now,
Kind: "config",
Key: "quiet_hours",
Value: val,
Source: "tap:voice",
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим."
}
return reply
}
// replySystem answers system-observable queries using the handler's clock
// and (in future) system interfaces. The decision's utterance is parsed
// for keywords to determine what the user is asking about.
func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) string {
u := strings.ToLower(dec.Utterance)
now := h.now()
switch {
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
h := now.Hour()
m := now.Minute()
hourWord := ruPlural(h, "час", "часа", "часов")
if m == 0 {
return fmt.Sprintf("сейчас %d %s ровно", h, hourWord)
}
minWord := ruPlural(m, "минута", "минуты", "минут")
return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord)
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
dow := ruWeekdays[now.Weekday()]
month := ruMonths[now.Month()-1]
return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year())
case strings.Contains(u, "погод") || strings.Contains(u, "градус") || strings.Contains(u, "дожд") || strings.Contains(u, "холод") || strings.Contains(u, "тепл"):
return "погода пока не подключена — нужен внешний сервис."
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
return "присутствие пока не подключено к голосовому запросу."
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
return "системная статистика пока не подключена."
default:
return "пока не умею отвечать на этот вопрос."
}
}
// reply wraps a text reply through TTS to produce a PushToTalkResp. If TTS
// fails, the response carries an empty audio + the text — the client can
// still display text if it can't play. The routedChannels field is
// reserved for a future "the dispatcher also forwarded to ntfy/telegram"
// reply (today the reactive path doesn't dispatch nudges; that's the loop
// tick's job).
func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) {
log.Printf("voice: reply → %q", text)
audioOut, err := h.tts.Synthesize(ctx, text)
if err != nil {
log.Printf("voice: tts error: %v", err)
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audio.Audio{}}, nil
}
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil
}
// buildRouter constructs the reactive-path router with the given embedder
// and confidence threshold.
// - stage-0 grammars from DefaultActMatcher whose fn allowlist is exactly
// the enabled tool names (actFns) — the router only matches acts the
// executor can run. Empty ⇒ every act refuses at the matcher.
// - The embedder is provided by wireVoice: HashEmbedder (floor) when no
// embedder config is present, or the ONNX multilingual model when
// configured — same interface, one constructor change.
// - 6 bootstrap examples covering the 5 intents + one compound-capture
// placeholder. Spec calls for ~10 per intent at production; this is the
// bootstrapping floor swapped by tuning the seed set later.
// - Threshold is from voice.router_threshold config (default 0.35).
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64) *router.Router {
cls := router.NewClassifier(emb)
seedClassifier(cls)
return router.New(router.Config{
Grammars: router.DefaultGrammars(acts),
Classifier: cls,
Extractor: router.Extractor{
Time: router.StubDateTimeParser{},
Acts: acts,
Facts: router.DefaultFactParser{},
},
Threshold: threshold,
})
}
// seedDir is the directory containing intent seed files. Each file is named
// <intent>.txt and contains one training example per line (blank lines and
// lines starting with # are ignored). Relative to the working directory.
const seedDir = "models/seeds"
// seedClassifier floors the embedded examples so the cold-boot path
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
// classifier can't decide it falls through to Clarify — the last-resort
// path asks the user to rephrase rather than guessing wrong.
func seedClassifier(c *router.Classifier) {
intents := []router.Intent{
router.IntentAct,
router.IntentReminder,
router.IntentFact,
router.IntentNote,
router.IntentQuery,
router.IntentSystem,
}
total := 0
for _, intent := range intents {
n, err := loadSeedFile(c, intent)
if err != nil {
log.Printf("voice: seed %s: %v", intent, err)
continue
}
total += n
}
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
}
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
path := filepath.Join(seedDir, string(intent)+".txt")
f, err := os.Open(path)
if err != nil {
return 0, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
var count int
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if err := c.AddExample(context.Background(), intent, line); err != nil {
log.Printf("voice: seed %s: skipping %q: %v", intent, line, err)
continue
}
count++
}
if err := sc.Err(); err != nil {
return count, fmt.Errorf("scan %s: %w", path, err)
}
return count, nil
}
// jsonString — a one-line JSON string encoder without dragging encoding/json
// into the top of this file. Used to wrap a reminder payload's text field;
// the router's reminder Slots are already absolute (DateTimeParser resolved
// relative→absolute), the payload shape is conventional {"text":...}.
func jsonString(s string) string {
return jsonStringImpl(s)
}
// park stores a destructive act awaiting confirmation. Overwrites any prior
// pending (last-asked wins — single-user box).
func (h *reactiveHandler) park(fn string, args []string, phrase string) {
h.mu.Lock()
h.pending = &pendingAct{fn: fn, args: args, phrase: phrase, expiry: h.now().Add(confirmTTL)}
h.mu.Unlock()
}
// resolveConfirm interprets an utterance as the answer to a parked destructive
// act. Returns (reply, true) when it consumed the utterance as a y/n answer;
// (\"\", false) when there's nothing pending (or the parked act expired), so the
// caller routes the utterance normally. An unrecognised answer cancels the
// pending act and routes normally — a confirm that can't be answered clearly is
// safer abandoned than left armed.
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
h.mu.Lock()
p := h.pending
if p == nil {
h.mu.Unlock()
return "", false
}
if h.now().After(p.expiry) {
h.pending = nil
h.mu.Unlock()
return "", false
}
switch classifyConfirm(text) {
case confirmYes:
h.pending = nil
h.mu.Unlock()
out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed
if err != nil {
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
if out != "" {
return "не получилось выполнить команду: " + firstLine(out), true
}
return "не получилось выполнить команду.", true
}
if out != "" {
return "готово: " + firstLine(out), true
}
return "готово.", true
case confirmNo:
h.pending = nil
h.mu.Unlock()
return "отменила.", true
default:
// unclear answer: abandon the confirm, route this utterance normally.
h.pending = nil
h.mu.Unlock()
return "", false
}
}
// proposeGap scaffolds a 'proposed' tool for an act whose verb isn't enabled.
// maven drafts the registration (name = the verb, provenance = the utterance);
// a human enables it on the authed surface. She suggests, never enables.
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
name := firstWord(stripWake(dec.Utterance))
if name == "" {
return "не разобрала команду — попробуй иначе."
}
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, h.now())
if err != nil {
log.Printf("voice: propose tool %q: %v", name, err)
return "команды «" + name + "» нет в списке разрешённых."
}
if newly {
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
}
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
}
// confirmVerdict — the parse of a y/n confirm answer.
type confirmVerdict int
const (
confirmUnknown confirmVerdict = iota
confirmYes
confirmNo
)
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
func classifyConfirm(text string) confirmVerdict {
t := strings.ToLower(strings.TrimSpace(text))
// negatives first — "не надо" contains no "да", but check no-stems before
// yes so a leading "нет" isn't shadowed.
for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} {
if strings.Contains(t, no) {
return confirmNo
}
}
for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} {
if strings.Contains(t, yes) {
return confirmYes
}
}
return confirmUnknown
}
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
func actPhrase(fn string, args []string) string {
if len(args) == 0 {
return fn
}
return fn + " " + strings.Join(args, " ")
}
// stripWake removes a leading "maven," wake token so the verb is the first word.
func stripWake(u string) string {
u = strings.TrimSpace(u)
low := strings.ToLower(u)
if strings.HasPrefix(low, "maven") {
u = strings.TrimSpace(u[len("maven"):])
u = strings.TrimLeft(u, ",:; ")
}
return u
}
// firstWord returns the first whitespace-delimited token (lowercased) — the
// proposed tool's name.
func firstWord(s string) string {
f := strings.Fields(s)
if len(f) == 0 {
return ""
}
return strings.ToLower(f[0])
}
// seedTools upserts the config-declared tools into the store as enabled. Editing
// mavend.json is a human act, so a config tool is enabled by definition; this
// makes the declarative config the reproducible bootstrap while the store stays
// the single runtime source of truth (mavweb enables ad-hoc ones on top).
func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) {
ctx := context.Background()
now := time.Now()
n := 0
for _, tc := range tools {
if tc.Name == "" || len(tc.Cmd) == 0 {
log.Printf("voice: skipping malformed tool config %+v", tc)
continue
}
if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, now); err != nil {
log.Printf("voice: seed tool %q: %v", tc.Name, err)
continue
}
n++
}
log.Printf("voice: seeded %d act tools from config", n)
}
// firstLine — the first non-empty line of a tool's output, for a short spoken
// reply (the full output goes to the log, not the TTS). Trimmed to keep the
// utterance sane if a command dumps a wall of text.
func firstLine(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line != "" {
if len(line) > 200 {
line = line[:200]
}
return line
}
}
return ""
}
func jsonStringImpl(s string) string {
// minimal JSON string escape — quotes + backslash + control chars.
// adequate for the reminder payload's text field; not a general JSON
// encoder. The chroma / RAG modules (when they land) use a real json
// encoder for richer payloads. Keep it inline here so the import
// direction stays narrow.
var b []byte
b = append(b, '"')
for _, r := range s {
switch r {
case '"':
b = append(b, '\\', '"')
case '\\':
b = append(b, '\\', '\\')
case '\n':
b = append(b, '\\', 'n')
case '\r':
b = append(b, '\\', 'r')
case '\t':
b = append(b, '\\', 't')
default:
if r < 0x20 {
b = append(b, []byte(fmt.Sprintf("\\u%04x", r))...)
} else {
b = append(b, []byte(string(r))...)
}
}
}
b = append(b, '"')
return string(b)
}