// actionTable dispatches applyAction's per-intent bodies. Each of the 7 // intents (fact, reminder, note, query, act, chat, system) has one handler // here with the signature: // // func(h *reactiveHandler, ctx context.Context, dec router.Decision) string // // same contract as applyAction itself: "" means "let the Replier phrase the // reply", a non-empty string OVERRIDES it. This is a straight extraction of // applyAction's old switch cases (formerly ~300 lines in voice.go) — no // reordering of side effects, no new abstractions inside a handler. // // What does NOT belong in this table, because it is not per-intent: // // - the dec.Clarify short-circuit ("" when the router's stage-3 fired) — // stays in applyAction, before dispatch, since it applies to every // intent identically. // - the destructive-act confirm gate (park / resolveConfirm / confirmTTL) // and the enabled-tool allowlist. Both live entirely inside // actionAct/handleAct below, exactly where they lived in the old // switch's IntentAct case — they are act-specific (a fact or a note // can't be destructive), not shared across intents, so they do not need // to move to a separate layer. The important invariant, preserved // as-is: applyAction runs identically whether dec came from a fresh // route or from a completed clarify answer (see finishClarified in // clarify.go and its comment "filling in an argument never grants // authority") — a handler must never special-case a clarify-completed // decision to skip the confirm gate or the allowlist. // - detectPattern and dialogue-session bookkeeping (rememberTurn, // followUpMerge) run in the callers (handleText, HandlePushToTalk, // finishClarified), not per-intent, and are untouched by this slice. // // Adding an intent: write its handler here, add one line to actionHandlers. // Do not grow applyAction's switch back. package main import ( "context" "errors" "fmt" "log" "strconv" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/weather" ) // actionHandlers is the per-intent dispatch table used by applyAction. var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, router.Decision) string{ router.IntentFact: (*reactiveHandler).actionFact, router.IntentReminder: (*reactiveHandler).actionReminder, router.IntentAct: (*reactiveHandler).actionAct, router.IntentChat: (*reactiveHandler).actionChat, router.IntentSystem: (*reactiveHandler).actionSystem, router.IntentNote: (*reactiveHandler).actionNote, router.IntentQuery: (*reactiveHandler).actionQuery, } func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string { 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, // Subject: the key doubles as the entity-resolution candidate — // a voice-tapped fact's key is usually the thing/person it's // about ("espresso_machine", "kate"), so queueing it for Nexus // resolution costs one async lookup and is a no-op (not_found) // for the abstract self-state keys (mood, water) that aren't // entities at all. Subject: dec.Slots.Key, } factID, err := h.api.WriteFact(ctx, req) if err != nil { log.Printf("voice: write fact: %v", err) return "не получилось сохранить факт." } // Index the fact utterance in long-term memory (best-effort, must not // fail the fact write). Facts aren't in the notes table, so this is the // only recall path for them — "когда я пил воду?" reads back from here. if h.memStore != nil { if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil { log.Printf("voice: embed fact for memory: %v", err) } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ "source": "voice", "type": "fact", "text": dec.Utterance, "ts": strconv.FormatInt(now.Unix(), 10), }); err != nil { log.Printf("voice: memory insert fact: %v", err) } } // Event extraction + pattern detection (best-effort, must not fail the // fact write). If the fact describes a recognizable action, it becomes a // normalized event; if ≥3 events for the same action+object show stable // intervals, a proposed routine is created and parked for confirmation. if h.dataStore != nil { if phrase := h.detectPattern(ctx, factID, dec.Slots.Key, dec.Slots.Value, now); phrase != "" { return phrase // "ты заправляешь ... напоминать?" } } return "" // replier phrases the success reply } func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string { if !dec.Slots.HasTime { // Stage-0 (reminder-wakeword grammar) skips the extractor, so the // time wasn't parsed. Run the parser as a fallback. if dec.Stage == 0 && h.timeParser != nil { t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now()) if err == nil && ok { dec.Slots.Time = t dec.Slots.HasTime = true } } 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 "" } func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string { // tool executor: run the matched fn against the enabled allowlist. // HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb // didn't go through the stage-0 act grammar). if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil { if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok { dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true } } // Praxis ecosystem tools: intercept before the system command executor. if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn { if reply := h.handlePraxisAct(ctx, dec); reply != "" { return reply } } // Hexis ecosystem action: if ecosystem is configured and we have a verb // + entity text, try to resolve the entity and execute via Hexis. if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" { if reply := h.handleHexisAct(ctx, dec); reply != "" { return reply } } // HasFn still 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 "готово." } func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string { // Conversational: build history from dialogue session (prior user turns) // and let the LLM respond from general knowledge + context. history := h.chatHistory() reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) if err != nil { log.Printf("voice: chat: %v", err) return "поговорили." } return reply } func (h *reactiveHandler) actionSystem(ctx context.Context, dec router.Decision) string { return h.replySystem(ctx, dec) } func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string { // 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 := router.EmbedPassage(ctx, h.embedder, dec.Utterance) if err != nil { log.Printf("voice: embed note: %v", err) return "не получилось сохранить заметку." } noteTs := h.now() noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") if err != nil { log.Printf("voice: write note: %v", err) return "не получилось сохранить заметку." } // Insert into long-term memory (best-effort, must not fail the note write). // text/ts in the meta make a Search hit self-describing (see bestRecall). if h.memStore != nil { if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ "source": "voice", "type": "note", "text": dec.Utterance, "ts": strconv.FormatInt(noteTs.Unix(), 10), }); err != nil { log.Printf("voice: memory insert: %v", err) } } return "" // replier phrases the "saved" reply } func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { // Fact-by-key lookup: when the dialogue layer resolved an anaphoric // reference to a prior fact's key (e.g. "когда я это сделал?" after // "запиши что я пил воду"), look up the fact's value directly. if dec.Slots.HasKey && dec.Slots.Key != "" { if f, err := h.api.LatestFact(ctx, dec.Slots.Key); err == nil { if dec.Slots.HasTime { // The query asks about timing — the fact's own timestamp // is the answer it's looking for. Format as a natural reply. reply := fmt.Sprintf("я записала это %s", formatTime(f.Ts)) return reply } // General fact reference: describe what we know. if dec.Utterance == "" { return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value) } // The utterance still carries the question; fall through to // normal RAG with the resolved key in context. } } // Calendar questions: "что у меня сегодня?", "планы на завтра?" // h.now(), not time.Now(): the handler's clock is the injected one, so // this arm can be tested at a fixed time like the rest. if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok { events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) return "не получилось проверить календарь." } values := make([]string, len(events)) for i, e := range events { values[i] = e.Value } var f router.CalendarEventFormatter return f.Format(values, date) } // Weather questions if isWeatherQuery(dec.Utterance) { loc := extractWeatherLocation(dec.Utterance, h.weatherLocation) ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) if errors.Is(err, weather.ErrNotConfigured) { return "погода не настроена." } if err != nil { log.Printf("voice: weather: %v", err) return "не получилось узнать погоду." } return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition) } vec, err := router.EmbedQuery(ctx, h.embedder, dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) return "не получилось найти ответ." } // Long-term memory first: ONE search over everything Maven remembers // (notes and facts share this index) and ONE confidence gate, so the // memory that is clearly the best match answers — a note just as much // as a fact. // // This used to run only after the notes-only gate below had already // rejected the same note at the same score, which no note could ever // survive a second time: the branch could only return a fact (#373). // Order, not the gate, was the bug — the set of questions Maven answers // is unchanged, only which memory gets to answer them. if h.memStore != nil { if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { if hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin); ok { text := hit.Meta["text"] // A note is phrased in Maven's voice; a fact is read back // as it was stored. if hit.Meta["type"] == "note" { if reply, perr := h.phraser.PhraseQuery(ctx, dec.Utterance, []string{text}); perr == nil && reply != "" { return reply } } return text } } else { log.Printf("voice: memory search: %v", herr) } } notes, err := h.api.QueryNotes(ctx, vec, 5) if err != nil { log.Printf("voice: query notes: %v", err) return "не получилось найти ответ." } // Notes-only pass, for notes the vector index above does not hold (an // older note written before it existed). Same gate, notes-only // candidates. // // Confidence gate: below it, 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. Two parts: an absolute cosine floor, // and a margin over the runner-up, which is the part that works with // the e5 embedder's narrow score band. See memory.Confident. noteScores := make([]float64, len(notes)) for i, n := range notes { noteScores[i] = n.Score } if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { // Try general knowledge from the phraser before giving up reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil) if err != nil || reply == "" { return "не знаю." } return reply } texts := make([]string, len(notes)) for i, n := range notes { texts[i] = n.Text } reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, texts) if err != nil { log.Printf("voice: phrase query: %v", err) } if reply == "" { reply = "вот что я нашла: " + texts[0] } return reply }