// 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" "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "strconv" "strings" "sync" "time" hexisclient "github.com/kami/hexis/pkg/client" "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/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/stt" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/ttsnorm" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/weather" "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 handler *reactiveHandler // the reactive handler for IPC Chat // 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, phr phraser.Phraser, memStore memory.Store, dataStore *store.Store, eco *ecosystemWiring) (*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 { log.Printf("voice: embedder not configured, using HashEmbedder floor") 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) // ----- weather provider (Open-Meteo when configured, Stub otherwise) ----- var weatherProvider weather.Provider var weatherLocation string if cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo" { weatherProvider = weather.NewOpenMeteoProvider() weatherLocation = cfg.Voice.Weather.DefaultLocation log.Printf("voice: weather provider: open-meteo (default location: %s)", cfg.Voice.Weather.DefaultLocation) } else { weatherProvider = weather.NewStubProvider() log.Printf("voice: weather provider: stub (not configured)") } // The replier uses the same llama-server as the phraser. var llmClient *llm.Client if lp, ok := phr.(*phraser.LLMPhraser); ok { llmClient = llm.New(lp.BaseURL(), 60*time.Second) } // LLM router disabled — the classifier handles routing reliably. // ----- 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, nil) // LLM router disabled // ----- 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) // ----- memory (long-term vector storage) ----- // Persistent (store-backed, survives restarts) when the daemon passes one; // falls back to the in-memory floor otherwise (tests / no-store paths). if memStore == nil { memStore = memory.NewInMemoryStore() } // ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) ----- dialogueSessions := dialogue.NewSessionStore(2 * time.Minute) // ----- replier (LLM-backed when the engine is on, Stub floor otherwise) ----- replier := voice.Replier(voice.NewStubReplier()) if llmClient != nil { replier = newLLMReplier(llmClient) } // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) ----- h := &reactiveHandler{ stt: transcriber, tts: synthesizer, router: rtr, embedder: emb, api: coreAPI, tools: exec, matcher: matcher, replier: replier, phraser: phr, now: time.Now, weatherProvider: weatherProvider, weatherLocation: weatherLocation, memStore: memStore, dataStore: dataStore, dialogueSessions: dialogueSessions, queryMinScore: cfg.Voice.QueryMinScore, timeParser: router.NewPythonDateParser(), ecosystem: eco, } // ----- 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 w.handler = h 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 matcher *tool.Matcher phraser phraser.Phraser replier voice.Replier now func() time.Time weatherProvider weather.Provider weatherLocation string // default location for weather queries memStore memory.Store dataStore *store.Store // direct store access for event extraction + pattern detection // queryMinScore — the note-recall confidence gate. Top cosine below this ⇒ // "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not // load-bearing math (same posture as the presence thresholds). Set by // wireVoice from VoiceConfig; default 0.55. queryMinScore float64 // timeParser — used as a fallback for stage-0 reminder grammar matches // (where the extractor didn't run). Shared with the router's extractor. // The production dateparser will replace StubDateTimeParser here too. timeParser router.DateTimeParser // dialogueSessions carries slots across turns for follow-ups (single-user // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore // 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 pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n ecosystem *ecosystemWiring // nexus + hexis + praxis clients } // pendingHexisExec — a mutating Hexis capability parked awaiting a spoken // confirm. The confirmation is bound to the resolved capability + canonical // target entity so a later "да" can only execute exactly what was proposed // (ecosystem invariant: protected actions require bound confirmation). type pendingHexisExec struct { capabilityID string capName string entityID string displayName string expiry time.Time } // pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become // a recurring reminder. Set by detectPattern after creating a proposal. type pendingRoutineConfirm struct { routineID int64 action string object string interval float64 phrase string expiry time.Time } // 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) } // 1c. quiet-hours toggle — keyword match, not classifier-dependent. // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it // before routing. Same pattern as the confirm turn above. if reply, handled := h.resolveQuietToggle(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) } // 2b. dialogue — fill this turn's missing slots from a prior same-intent // turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember // this turn for the next follow-up. Only same-intent, non-expired, non- // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. if h.dialogueSessions != nil { now := h.now() prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) if !dec.Clarify { // Build history: carry over up to 4 prior turns for cross-intent // reference. The most recent prior turn is prepended to history. var history []dialogue.Turn if prev != nil { history = append(history, dialogue.Turn{ Intent: prev.Intent, Slots: prev.Slots, Text: prev.Slots.Text, // the prior turn's utterance }) // Cap history depth so one long conversation can't grow // the session unboundedly. maxHist := len(prev.History) if maxHist > 3 { maxHist = 3 } history = append(history, prev.History[:maxHist]...) } ttl := time.Duration(0) // use default (2 min) if dec.Intent == router.IntentChat { ttl = 15 * time.Minute // conversational turns should last longer } h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ Intent: dialogue.Intent(dec.Intent), Slots: toDialogueSlots(dec.Slots), Timestamp: now, TTL: ttl, History: history, }) } } // 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) } // handleText — the core reactive path without stt/tts: confirm check → // route → dialogue → action → replier. Used by the IPC Chat endpoint // (and eventually by telegram). Splits out the audio bookends from // HandlePushToTalk so text channels share the same routing logic. func (h *reactiveHandler) handleText(ctx context.Context, text string) string { log.Printf("voice: handleText: %q", text) // 1b. confirm turn — if a destructive act is parked, this utterance is its // y/n answer. Same check as HandlePushToTalk. if reply, handled := h.resolveConfirm(ctx, text); handled { return reply } // 2. router — classify the utterance. dec, err := h.router.Route(ctx, text, h.now()) if err != nil { if errors.Is(err, router.ErrNoIntents) { return "я ещё не понимаю свободную речь — скоро научусь." } log.Printf("voice: handleText router error: %v", err) return "не получилось разобрать команду." } log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots) // 2b. dialogue — same as HandlePushToTalk. if h.dialogueSessions != nil { now := h.now() prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) if !dec.Clarify { var history []dialogue.Turn if prev != nil { history = append(history, dialogue.Turn{ Intent: prev.Intent, Slots: prev.Slots, Text: prev.Slots.Text, }) maxHist := len(prev.History) if maxHist > 3 { maxHist = 3 } history = append(history, prev.History[:maxHist]...) } ttl := time.Duration(0) if dec.Intent == router.IntentChat { ttl = 15 * time.Minute } h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ Intent: dialogue.Intent(dec.Intent), Slots: toDialogueSlots(dec.Slots), Timestamp: now, TTL: ttl, History: history, }) } } // 3. action — execute the decision's intent. replyText := h.applyAction(ctx, dec) log.Printf("voice: applyAction returned: %q", replyText) // 4. replier — phrase the reply when applyAction returned "". if replyText == "" { replyText = h.replier.Reply(dec) } return replyText } // 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. 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, // 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 := h.embedder.Embed(ctx, 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 case router.IntentReminder: 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 "" case router.IntentAct: // 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 "готово." case router.IntentChat: // 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 case router.IntentSystem: 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 "не получилось сохранить заметку." } 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 case router.IntentQuery: // 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: "что у меня сегодня?", "планы на завтра?" if date, ok := router.ParseCalendarDate(dec.Utterance, time.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 := 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 < h.queryMinScore { // Long-term memory recall (notes + facts) before general knowledge: // the notes table can't answer fact questions, but the memory store // indexes both. Only runs when notes-RAG already gave up → additive. if h.memStore != nil { if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { if text, ok := bestRecall(hits, h.queryMinScore); ok { return text } } } // 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 } return "" } // detectPattern extracts an event from the written fact and runs the pattern // detector. If a stable recurring pattern is found and no proposed routine // exists for this action+object yet, one is created and the user is prompted // to confirm via the park() mechanism. Returns the suggestion phrase when a // new proposal was created and parked; "" otherwise. func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string { ev := pattern.Extract(factID, key, value, ts) if ev == nil { return "" // not an actionable event } if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil { log.Printf("voice: create event: %v", err) return "" } events, err := h.dataStore.EventsFor(ctx, ev.Action, ev.Object) if err != nil { log.Printf("voice: events for %s/%s: %v", ev.Action, ev.Object, err) return "" } // Convert store.Events to pattern.Events for the detector. patEvents := make([]pattern.Event, len(events)) for i, e := range events { patEvents[i] = pattern.Event{ FactID: e.FactID, Action: e.Action, Object: e.Object, Ts: e.Ts, } } r, err := pattern.Detect(patEvents) if err != nil { log.Printf("voice: pattern detect: %v", err) return "" } if r == nil { return "" // not enough data or intervals too irregular } // Check if already proposed/accepted/dismissed for this pair. existing, err := h.dataStore.LookupProposedRoutine(ctx, r.Action, r.Object) if err != nil { log.Printf("voice: lookup proposed routine: %v", err) return "" } if existing != nil { return "" // already proposed, accepted, or dismissed } id, err := h.dataStore.CreateProposedRoutine(ctx, r.Action, r.Object, r.IntervalDays, ts) if err != nil { log.Printf("voice: create proposed routine: %v", err) return "" } log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays) // Park the proposal for voice confirmation. phrase := pattern.PhraseRoutine(r) h.mu.Lock() h.pendingRoutine = &pendingRoutineConfirm{ routineID: id, action: r.Action, object: r.Object, interval: r.IntervalDays, phrase: phrase, expiry: ts.Add(confirmTTL), } h.mu.Unlock() return phrase } 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 } } // resolveQuietToggle — pre-route keyword check. Returns (reply, true) when // the utterance is a quiet-on/off command; ("", false) otherwise. Called from // HandlePushToTalk BEFORE the router so a classifier miscue can't drop it. func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) { u := strings.ToLower(strings.TrimSpace(text)) var on, off bool // Match as whole-token phrases so "тихий" in "тихий режим включи" still // catches, but "тихий" alone in "очень тихий сегодня день" doesn't fire. // The confirm turn is handled above, so "да"/"нет" won't reach here. for _, kw := range []string{"quiet on", "quiet mode", "тихий режим", "тихий", "не шуми", "не беспокоить", "тихо"} { if strings.Contains(u, kw) { on = true break } } if !on { for _, kw := range []string{"quiet off", "quiet end", "громкий режим", "шумный режим", "отмени тихий", "выключи тихий", "не тихо"} { if strings.Contains(u, kw) { off = true break } } } if !on && !off { return "", false } val := "false" reply := "тихий режим выключен." if on { val = "true" reply = "тихий режим включён. буду реже напоминать." } if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ Ts: h.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 "не получилось переключить тихий режим.", true } return reply, true } // 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() // stage-0 grammars catch the exact time/date patterns, but duration // queries ("сколько времени прошло") bypass the grammar's build filter // and can reach replySystem via the classifier path. Guard against them. if hasDurationWords(u) { return "пока не умею отвечать на этот вопрос." } 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, "человек дома"): 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 "пока не умею отвечать на этот вопрос." } } // chatHistory collects dialogue turns from the session store for the current // conversation. Returns prior user utterances (newest last) up to a depth of // 4 turns. Returns nil when there's no session or no history. func (h *reactiveHandler) chatHistory() []dialogue.Turn { if h.dialogueSessions == nil { return nil } now := h.now() prev := h.dialogueSessions.Get(voiceDialogueID, now) if prev == nil { return nil } // History already includes the immediate prior turn (set by the dialogue // merge at lines 373-395), plus up to 3 more from deeper history. out := make([]dialogue.Turn, 0, 1+len(prev.History)) out = append(out, dialogue.Turn{ Intent: prev.Intent, Slots: prev.Slots, Text: prev.Slots.Text, }) out = append(out, prev.History...) return out } // hasDurationWords checks whether u is asking about elapsed/remaining time // rather than the current clock — guards replySystem from replying "сейчас // X часов" to "сколько времени прошло". Mirrors the stage0.go build filter. func hasDurationWords(u string) bool { s := strings.ToLower(strings.TrimSpace(u)) // First-word duration markers (same keywords as timeQueryBuild in stage0). first := strings.Fields(s) if len(first) > 0 { switch first[0] { case "прошло", "осталось", "пройдет", "минуло", "проходит": return true } } // Broader duration keywords appearing anywhere in the utterance. if strings.Contains(s, "прошло") || strings.Contains(s, "осталось") { return true } if strings.Contains(s, " до ") { return true } return false } // 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) { spoken := ttsnorm.Speakable(text) log.Printf("voice: reply → %q", text) audioOut, err := h.tts.Synthesize(ctx, spoken) 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.55). func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router { cls := router.NewClassifier(emb) seedClassifier(cls) grammars := router.DefaultGrammars(acts) grammars = append(grammars, router.SystemTimeDateGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, Classifier: cls, Extractor: router.Extractor{ Time: router.NewPythonDateParser(), Acts: acts, Facts: router.DefaultFactParser{}, }, Threshold: threshold, LLM: llmR, }) } // seedDir is the directory containing intent seed files. Each file is named // .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.IntentChat, 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 } // handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API. // Returns "" when the act is not a Praxis verb (the caller falls through to the // system command executor). Returns a reply string otherwise. func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string { if h.ecosystem == nil || h.ecosystem.praxis == nil { return "" } px := h.ecosystem.praxis fn := dec.Slots.Fn // Map verbs and Russian aliases to Praxis tool calls. // Each case: if the verb matches, call the tool and return a user-facing reply. switch fn { case "list_attention", "attention", "внимание", "что требует внимания", "что нового": items, err := px.ListAttention(ctx, 20) if err != nil { log.Printf("ecosystem: praxis attention: %v", err) return "не могу сейчас узнать, что требует внимания." } if len(items) == 0 { return "ничего не требует внимания." } h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)}) var parts []string for _, item := range items { title, _ := item["title"].(string) // importance arrives as JSON number ⇒ float64 over the HTTP contract. importance, _ := item["importance"].(float64) rule, _ := item["rule"].(string) s := title if importance > 0 { s += fmt.Sprintf(" (важность %d", int(importance)) if rule != "" { s += ": " + rule } s += ")" } parts = append(parts, s) // Speaking an item surfaces it, it does not acknowledge it // (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort: // a failed surface call must not block delivering the digest. if id, ok := item["id"].(string); ok && id != "" { if _, err := px.Surface(ctx, id); err != nil { log.Printf("ecosystem: praxis surface %s: %v", id, err) } } } return "требует внимания: " + strings.Join(parts, "; ") case "acknowledge_item", "принято", "понял", "поняла": id := dec.Slots.Value if id == "" { return "какой пункт отметить принятым?" } if _, err := px.Acknowledge(ctx, id); err != nil { log.Printf("ecosystem: praxis acknowledge %s: %v", id, err) return "не получилось отметить принятым." } h.recordPraxisTrace(ctx, "acknowledge", map[string]any{"item_id": id}) return "принято." case "resolve_item", "сделано", "готово", "решено": id := dec.Slots.Value if id == "" { return "какой пункт отметить сделанным?" } if _, err := px.Resolve(ctx, id); err != nil { log.Printf("ecosystem: praxis resolve %s: %v", id, err) return "не получилось отметить сделанным." } h.recordPraxisTrace(ctx, "resolve", map[string]any{"item_id": id}) return "отмечено как сделано." case "ignore_item", "игнорировать", "неважно": id := dec.Slots.Value if id == "" { return "какой пункт игнорировать?" } if _, err := px.Ignore(ctx, id); err != nil { log.Printf("ecosystem: praxis ignore %s: %v", id, err) return "не получилось проигнорировать." } h.recordPraxisTrace(ctx, "ignore", map[string]any{"item_id": id}) return "проигнорировано." case "pin_item", "закрепить": id := dec.Slots.Value if id == "" { return "какой пункт закрепить?" } if _, err := px.Pin(ctx, id, true); err != nil { log.Printf("ecosystem: praxis pin %s: %v", id, err) return "не получилось закрепить." } h.recordPraxisTrace(ctx, "pin", map[string]any{"item_id": id}) return "закреплено." case "list_changes", "changes", "изменения", "что изменилось": changes, err := px.ListChanges(ctx, 20) if err != nil { log.Printf("ecosystem: praxis changes: %v", err) return "не могу сейчас узнать об изменениях." } if len(changes) == 0 { return "нет изменений." } h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)}) var parts []string for _, c := range changes { title, _ := c["title"].(string) typ, _ := c["change_type"].(string) parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } return "изменения: " + strings.Join(parts, "; ") default: // Not a Praxis verb — let the caller fall through. return "" } } // recordPraxisTrace — writes a fact recording a cross-service ecosystem call. // The fact is stored with source "praxis:trace" so the proactive loop can // reference it and the dashboard can display recent ecosystem activity. func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) { now := h.now() value := operation if len(details) > 0 { if b, err := json.Marshal(details); err == nil { value = operation + " " + string(b) } } _, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{ Ts: now, Kind: "system", Key: "praxis:" + operation, Value: value, Source: "praxis:trace", Confidence: 1.0, }) } // handleHexisAct — resolves entity references through Nexus and executes // matching capabilities through Hexis. Returns a reply string when handled, // or "" to fall through to the system command executor. func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string { if h.ecosystem == nil { return "" } // Resolve the utterance text as an entity reference through Nexus. An // ambiguous match must stop and clarify — never guess a mutation target. entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil) if err != nil { // A genuine Nexus dependency failure, not "no such entity" — stop here // and report degradation rather than silently falling through to the // local command executor (ECOSYSTEM-SPEC.md: services degrade // independently, never a silent all-clear). return "экосистема недоступна, попробуй ещё раз." } if len(ambiguous) > 0 { return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" } if entityID == "" { return "" } // Discover Hexis capabilities for this entity. A resolved entity with a // genuine Hexis failure must not be treated as "no capabilities" and // fall through to unrelated local execution. caps, err := h.ecosystem.discoverCapabilities(ctx, entityID) if err != nil { return "экосистема недоступна, попробуй ещё раз." } if len(caps) == 0 { return "" } // Match the user's verb to a capability by name/description. Collect all // matches: more than one is itself ambiguous, so we ask rather than pick // the first (ecosystem invariant: no arbitrary target for mutation). verb := dec.Slots.Fn if verb == "" { verb = dec.Slots.Text } verbLower := strings.ToLower(verb) var matches []*hexisclient.Capability for i, c := range caps { if strings.Contains(strings.ToLower(c.Name), verbLower) || (c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) { matches = append(matches, &caps[i]) } } if len(matches) == 0 { return "" } if len(matches) > 1 { var names []string for _, m := range matches { names = append(names, m.Name) } return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?" } matched := matches[0] // Read-only capabilities run immediately; mutating ones are parked for an // explicit spoken confirm bound to this capability + target. if !matched.ReadOnly { h.mu.Lock() h.pendingHexis = &pendingHexisExec{ capabilityID: matched.ID, capName: matched.Name, entityID: entityID, displayName: displayName, expiry: h.now().Add(confirmTTL), } h.mu.Unlock() return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»." } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) } // execHexis runs a resolved capability and records a cross-service trace with // the correlation ID. It reports command success, never operational recovery // (Praxis observes recovery independently). func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string { correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil) if err != nil { log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err) return "не получилось выполнить команду для " + displayName + "." } h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{ "entity_id": entityID, "entity_name": displayName, "capability": capName, "correlation_id": correlationID, }) return "команда выполнена для " + displayName + "." } // 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 OR a parked routine proposal. 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 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() defer h.mu.Unlock() // Check routine proposal first (newer feature; checked before tool confirm // so a routine confirm doesn't get eaten by a stale tool pending). pr := h.pendingRoutine if pr != nil && !h.now().After(pr.expiry) { switch classifyConfirm(text) { case confirmYes: h.pendingRoutine = nil // Create a recurring reminder at the detected interval. // Weekly patterns get a cron expression; arbitrary intervals // fire once and the detector re-proposes on the next cycle. intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour)) fire := h.now().Add(intervalDur) cron := "" if pr.interval >= 6.5 && pr.interval <= 7.5 { cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday())) } payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object) remID, err := h.api.CreateReminder(ctx, fire, payload, cron) if err != nil { log.Printf("voice: create routine reminder: %v", err) return "не получилось поставить напоминание.", true } if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil { log.Printf("voice: accept proposed routine: %v", err) } return "буду напоминать.", true case confirmNo: h.pendingRoutine = nil if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { log.Printf("voice: dismiss proposed routine: %v", err) } return "хорошо, не буду.", true default: // unclear: abandon the routine proposal, route normally. h.pendingRoutine = nil return "", false } } // Clear expired routine if it existed. if pr != nil { h.pendingRoutine = nil } // Check pending Hexis execution confirm. Bound to the exact capability + // target that was proposed; a stray "да" can only run that, nothing else. if hx := h.pendingHexis; hx != nil { if h.now().After(hx.expiry) { h.pendingHexis = nil } else { switch classifyConfirm(text) { case confirmYes: h.pendingHexis = nil return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName), true case confirmNo: h.pendingHexis = nil return "отменила.", true default: h.pendingHexis = nil return "", false } } } // Check tool confirm (existing behavior). p := h.pending if p == nil { return "", false } if h.now().After(p.expiry) { h.pending = nil return "", false } switch classifyConfirm(text) { case confirmYes: h.pending = nil 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 return "отменила.", true default: // unclear answer: abandon the confirm, route this utterance normally. h.pending = nil 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 wake token (any script the STT phonetically // transcribes "Maven" as) so the verb is the first word. func stripWake(u string) string { stripped, had := router.StripWakeToken(u) if !had { return strings.TrimSpace(u) } return stripped } // 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, tc.Scope, 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 "" } // isWeatherQuery returns true if the utterance is about weather. func isWeatherQuery(u string) bool { lower := strings.ToLower(u) return strings.Contains(lower, "погод") || strings.Contains(lower, "градус") || strings.Contains(lower, "температур") || strings.Contains(lower, "дожд") || strings.Contains(lower, "холод") || strings.Contains(lower, "тепл") || strings.Contains(lower, "weather") || strings.Contains(lower, "temperature") } // extractWeatherLocation parses a location from the utterance, or falls back // to the configured default. Very basic: just checks for known city names. func extractWeatherLocation(u, defaultLoc string) string { lower := strings.ToLower(u) cities := map[string]string{ "москв": "Moscow", "moscow": "Moscow", "питер": "Saint Petersburg", "spb": "Saint Petersburg", "петербур": "Saint Petersburg", "лондон": "London", "london": "London", "париж": "Paris", "paris": "Paris", "берлин": "Berlin", "berlin": "Berlin", "нью-йорк": "New York", "new york": "New York", } for substr, name := range cities { if strings.Contains(lower, substr) { return name } } if defaultLoc != "" { return defaultLoc } return "Moscow" } // formatTime returns a human-readable Russian time string for a fact timestamp. // Used by the query handler when answering "когда я это сделал?"-style questions. func formatTime(t time.Time) string { now := time.Now() if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) { return "только что" } diff := now.Sub(t) switch { case diff < 10*time.Minute: return "несколько минут назад" case diff < 60*time.Minute: return fmt.Sprintf("%d минут назад", int(diff.Minutes())) case diff < 2*time.Hour: return "час назад" case diff < 24*time.Hour: return fmt.Sprintf("%d часа назад", int(diff.Hours())) default: return t.Format("2 января 15:04") } } 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) }