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 "" +}