From b4a3867479af51fd06ae964afa877c766a74e406 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 00:51:44 +0400 Subject: [PATCH 1/2] Turn actionQuery into a chain of query sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six answer sources were hand-unrolled inside one 127-line function. The intent table is a closed set of 7, but this list is open-ended — Kiwix (#286), RSS (#258), the crawler (#259) and email (#246) each add one. Each is now a registry entry: a name plus a method on the handler, walked in order until one claims the question. Order is unchanged and still load-bearing (memory before the notes-only pass, #373), the confidence gate keeps its position and semantics, and every reply string, log line and best-effort failure is verbatim. --- cmd/mavend/actions.go | 132 --------------------- cmd/mavend/actions_query.go | 226 ++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 132 deletions(-) create mode 100644 cmd/mavend/actions_query.go diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index 83a874e..5a455a9 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -36,16 +36,12 @@ 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. @@ -232,131 +228,3 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s } 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 -} diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go new file mode 100644 index 0000000..9ce9972 --- /dev/null +++ b/cmd/mavend/actions_query.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/weather" +) + +// queryTurn is the per-turn scratch a chain of query sources shares: the +// decision being answered plus the work an earlier source already paid for +// (the query embedding, the notes it pulled). Sources read and fill it in +// order, so a later source never re-embeds. +type queryTurn struct { + dec router.Decision + vec []float32 + notes []ipc.Note +} + +// querySource — one answer source in the chain actionQuery walks. answer +// returns (reply, true) when this source claims the question, ("", false) +// when it passes to the next one. name is for reading the table, not logged. +// +// A struct of one func rather than an interface: every source is a plain +// method on *reactiveHandler with no state of its own (what state a turn has +// lives in queryTurn), so an interface would mean one empty type per source +// to satisfy it — ceremony for nothing. Same reasoning as confirmResolver in +// confirm.go, and the table then reads like actionHandlers: a flat list of +// method expressions you extend with one line. +type querySource struct { + name string + answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool) +} + +// querySources is the ordered chain actionQuery walks; first source to claim +// answers the turn. THE ORDER IS LOAD-BEARING — see the memory-before-notes +// comment on queryMemory: running the notes-only pass first was #373, and the +// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one +// line here plus its method; where you put the line is the whole decision. +var querySources = []querySource{ + {"fact-by-key", (*reactiveHandler).queryFactByKey}, + {"calendar", (*reactiveHandler).queryCalendar}, + {"weather", (*reactiveHandler).queryWeather}, + {"embed", (*reactiveHandler).queryEmbed}, + {"memory", (*reactiveHandler).queryMemory}, + {"notes", (*reactiveHandler).queryNotes}, + {"general-knowledge", (*reactiveHandler).queryGeneral}, +} + +func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { + t := &queryTurn{dec: dec} + for _, src := range querySources { + if reply, ok := src.answer(h, ctx, t); ok { + return reply + } + } + return "не знаю." +} + +// queryFactByKey — when the dialogue layer resolved an anaphoric reference to +// a prior fact's key (e.g. "когда я это сделал?" after "запиши что я пил +// воду"), look up the fact's value directly. +func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (string, bool) { + dec := t.dec + if !dec.Slots.HasKey || dec.Slots.Key == "" { + return "", false + } + f, err := h.api.LatestFact(ctx, dec.Slots.Key) + if err != nil { + return "", false + } + 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. + return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true + } + // General fact reference: describe what we know. + if dec.Utterance == "" { + return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true + } + // The utterance still carries the question; fall through to normal RAG + // with the resolved key in context. + return "", false +} + +// queryCalendar — "что у меня сегодня?", "планы на завтра?" +// h.now(), not time.Now(): the handler's clock is the injected one, so this +// source can be tested at a fixed time like the rest. +func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) { + date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now()) + if !ok { + return "", false + } + events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) + if err != nil { + log.Printf("voice: calendar events: %v", err) + return "не получилось проверить календарь.", true + } + values := make([]string, len(events)) + for i, e := range events { + values[i] = e.Value + } + var f router.CalendarEventFormatter + return f.Format(values, date), true +} + +func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { + if !isWeatherQuery(t.dec.Utterance) { + return "", false + } + loc := extractWeatherLocation(t.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 "погода не настроена.", true + } + if err != nil { + log.Printf("voice: weather: %v", err) + return "не получилось узнать погоду.", true + } + return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true +} + +// queryEmbed isn't an answer source — it's the shared cost the two recall +// sources below both need, run once, in the position it always ran in. It +// only claims the turn when the embedder fails. +func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) { + vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) + if err != nil { + log.Printf("voice: embed query: %v", err) + return "не получилось найти ответ.", true + } + t.vec = vec + return "", false +} + +// queryMemory — 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 source 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. +func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) { + if h.memStore == nil { + return "", false + } + hits, herr := h.memStore.Search(ctx, t.vec, 3) + if herr != nil { + log.Printf("voice: memory search: %v", herr) + return "", false + } + hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin) + if !ok { + return "", false + } + 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, t.dec.Utterance, []string{text}); perr == nil && reply != "" { + return reply, true + } + } + return text, true +} + +// queryNotes — 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. Failing the gate passes the turn on to general +// knowledge, which is what "don't read back the runner-up" means here. +func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) { + notes, err := h.api.QueryNotes(ctx, t.vec, 5) + if err != nil { + log.Printf("voice: query notes: %v", err) + return "не получилось найти ответ.", true + } + t.notes = notes + noteScores := make([]float64, len(notes)) + for i, n := range notes { + noteScores[i] = n.Score + } + if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { + return "", false + } + texts := make([]string, len(notes)) + for i, n := range notes { + texts[i] = n.Text + } + reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, texts) + if err != nil { + log.Printf("voice: phrase query: %v", err) + } + if reply == "" { + reply = "вот что я нашла: " + texts[0] + } + return reply, true +} + +// queryGeneral — general knowledge from the phraser, the last source before +// giving up. It always claims: either the model answers or Maven says she +// doesn't know. +func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) { + reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil) + if err != nil || reply == "" { + return "не знаю.", true + } + return reply, true +} From b09967f9e60892e70e44e5cb12106b7a7c495779 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 00:53:37 +0400 Subject: [PATCH 2/2] Split actions.go into per-intent files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move: actionFact, actionReminder, actionAct and actionNote each get their own actions_.go. The two small ones (chat, system) and the actionHandlers table stay in actions.go, which is now just the dispatch layer and the notes about what does not belong in it. No behaviour change — only the file a handler is read in. --- cmd/mavend/actions.go | 171 ++------------------------------- cmd/mavend/actions_act.go | 66 +++++++++++++ cmd/mavend/actions_fact.go | 64 ++++++++++++ cmd/mavend/actions_note.go | 41 ++++++++ cmd/mavend/actions_reminder.go | 33 +++++++ 5 files changed, 210 insertions(+), 165 deletions(-) create mode 100644 cmd/mavend/actions_act.go create mode 100644 cmd/mavend/actions_fact.go create mode 100644 cmd/mavend/actions_note.go create mode 100644 cmd/mavend/actions_reminder.go diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index 5a455a9..da2705d 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -16,7 +16,7 @@ // 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 +// actionAct/handleAct in actions_act.go, 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 @@ -29,19 +29,18 @@ // 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. +// Each handler lives in actions_.go; the small ones (chat, system) +// and the table itself stay here. +// +// Adding an intent: write its handler in its own file, add one line to +// actionHandlers. Do not grow applyAction's switch back. package main import ( "context" - "errors" "log" - "strconv" - "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" - "github.com/kami/maven/internal/tool" ) // actionHandlers is the per-intent dispatch table used by applyAction. @@ -55,134 +54,6 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r 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. @@ -198,33 +69,3 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s 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 -} diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go new file mode 100644 index 0000000..d5a1dc1 --- /dev/null +++ b/cmd/mavend/actions_act.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "log" + + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/tool" +) + +// actionAct handles router.IntentAct: match a verb to an enabled tool, offer +// it to the ecosystems first, and run it behind the confirm gate and the +// allowlist. proposeGap and the confirm gate itself live in confirm.go. +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 "готово." +} diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go new file mode 100644 index 0000000..855bf4b --- /dev/null +++ b/cmd/mavend/actions_fact.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "log" + "strconv" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// actionFact handles router.IntentFact: persist a tapped self-fact, index +// it for recall, and let pattern detection propose a routine. +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 +} diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go new file mode 100644 index 0000000..40e41cb --- /dev/null +++ b/cmd/mavend/actions_note.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "log" + "strconv" + + "github.com/kami/maven/internal/router" +) + +// actionNote handles router.IntentNote: embed the note, persist it, and +// index it for recall. +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 +} diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go new file mode 100644 index 0000000..ce2a632 --- /dev/null +++ b/cmd/mavend/actions_reminder.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log" + + "github.com/kami/maven/internal/router" +) + +// actionReminder handles router.IntentReminder: parse the time when stage-0 +// skipped the extractor, then create the reminder. +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 "" +}