// Package main is mavend's voice wiring + reactive handler. // // Two responsibilities for the audio path: // // 1. CONSTRUCTION: read cfg.Voice, build the stt/tts/transcribers (Stub // in-process by default, Remote via worker socket when configured), // the router (stage-0 grammar + HashEmbedder classifier seeded with // floor examples — production swaps in the ONNX multilingual model // later), the voice TCP listener, the sessions registry, the // voicesink, and wire the voicesink into the dispatcher's Voice slot. // // 2. HANDLER: a concrete voice.Handler that processes PushToTalk // requests: stt → router → action → replier → tts → reply. The // handler is what makes the audio round-trip "live". It wires to the // CoreAPI in-process (the daemon already has it as ipc.NewStoreAPI(st) // for module-IPC — the reactive path uses the same CoreAPI off the // same store; both are the "core = the only key-holder" path through // the daemon-embedded adapter). // // The "actions" handled today (per spec order; some deferred): // // - IntentFact: WriteFact via CoreAPI. The router's Slots.Key/Value feed // the write; Source = "tap:voice" (the voice path is a tap, value=1.0 // confidence — the user said it out loud, maven trusts the capture). // - IntentReminder: CreateReminder via CoreAPI. The router already // resolved relative→absolute at capture ("in 4h" → fire_ts); the // CoreAPI stores it as-is. // - IntentAct: the tool executor runs the matched fn against the store's // ENABLED allowlist (internal/tool). A verb not on it is scaffolded as a // 'proposed' tool a human enables on the authed mavweb surface (never // voice). Destructive tools run only after a spoken confirm turn. // - IntentNote: chroma/vector-store deferred. The handler replies // "saved" without persisting — a stub on the way to chroma. // - IntentQuery: RAG-over-chroma deferred. The handler replies "I'll // look that up later" — same shape as the other deferred slots. // - Clarify: the router's stage-3 confidence gate fired; reply "didn't // catch that, can you rephrase?" // // The Replier (voice.StubReplier today) renders the reply TEXT across all // these branches. The TTS synthesiser (tts.Stub today) renders that text // to audio. The PushToTalkResp carries BOTH so the client can play (audio) // AND log (text) for tests asserting the round-trip. package main import ( "bufio" "context" "errors" "fmt" "log" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/delivery/voicesink" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/stt" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/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 // 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) (*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)") } // ----- router (the cascade; floor examples seed the classifier) ----- // The act matcher's allowlist is exactly the enabled tool names — the // router only matches acts the executor can run (one source of truth). threshold := cfg.Voice.RouterThreshold if threshold <= 0 { threshold = config.DefaultRouterThreshold } rtr := buildRouter(emb, matcher, threshold) // ----- sessions registry (shared with voicesink) ----- sessions := voice.NewSessions() w.sessions = sessions // ----- voice sink (proactive nudges: dispatcher → voicesink → tts → push to client) ----- w.voiceSink = voicesink.New(synthesizer, sessions) // ----- memory (long-term vector storage, in-memory for now) ----- memStore := memory.NewInMemoryStore() // ----- 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, phraser: phr, replier: voice.NewStubReplier(), now: time.Now, weatherProvider: weatherProvider, weatherLocation: weatherLocation, memStore: memStore, } // ----- the server (TCP listener) ----- srv := voice.NewServer(cfg.Voice.Bind, h, sessions) if err := srv.Listen(); err != nil { w.close() return nil, fmt.Errorf("voice listen: %w", err) } w.server = srv return w, nil } // reactiveHandler — voice.Handler implementation. One method: turn a // PushToTalkReq into a reply (audio + text). The handler is concurrency- // safe (the wired stt/tts/router/api all are); called from per-conn // goroutines on the voice.Server. type reactiveHandler struct { stt stt.Transcriber tts tts.Synthesizer router *router.Router embedder router.Embedder // reused for note write/query (same model as the classifier) api ipc.CoreAPI tools *tool.Executor phraser phraser.Phraser replier voice.Replier now func() time.Time weatherProvider weather.Provider weatherLocation string // default location for weather queries memStore memory.Store // pending destructive-act confirmation. A destructive act replies with a // "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as // the y/n answer. ponytail: single slot, single-user box — a second act // while one waits overwrites it (last-asked wins); expires after confirmTTL. mu sync.Mutex pending *pendingAct } // pendingAct — a destructive act awaiting a spoken confirm. type pendingAct struct { fn string args []string phrase string expiry time.Time } // confirmTTL — how long a parked destructive confirm stays answerable. Short: // a confirm is a same-breath gesture; a stale prompt shouldn't fire on an // unrelated later "да". const confirmTTL = 90 * time.Second // HandlePushToTalk — the full reactive round-trip. Each step's failure // surfaces as a short reply text + empty audio OR an error; the voice // server translates an error into a wire RpcError. Today the handler // prefers a canned error-reply over an error return (a user-facing "didn't // catch that" is better than a wire error the client surfaces as // "internal"); the only error returned is a synthesizer fault (no audio // to ship back). func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushToTalkReq, _ uint64) (voice.PushToTalkResp, error) { // 1. stt — transcribe the audio. text, _, err := h.stt.Transcribe(ctx, req.Audio) if err != nil { log.Printf("voice: stt error: %v", err) return h.reply(ctx, "не получилось разобрать речь — попробуй ещё раз.", nil) } if text == "" { return h.reply(ctx, "ничего не услышала — попробуй ещё раз.", nil) } log.Printf("voice: stt → %q", text) // 1b. confirm turn — if a destructive act is parked, this utterance is its // y/n answer, not a fresh command. Handled before routing so "да" doesn't // get classified as some other intent. if reply, handled := h.resolveConfirm(ctx, text); handled { return h.reply(ctx, reply, nil) } // 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) } // 3. action — execute the decision's intent. errors here surface as // short reply text (the user wants to know the action didn't land); // the round-trip stays alive. replyText := h.applyAction(ctx, dec) // 4. replier — phrase the reply across the router decision. if replyText == "" { replyText = h.replier.Reply(dec) } // 5. tts — synthesise the reply text; return to the voice server which // ships it back on the conn. return h.reply(ctx, replyText, nil) } // applyAction — executes the router's Decision. Intent-by-intent: // // - IntentFact: WriteFact via CoreAPI. Source = "tap:voice" (a voice // capture is a tap; confidence 1.0). // - IntentReminder: CreateReminder via CoreAPI. // - IntentAct: tool-executor deferred (no-op today; the reply says so). // - IntentNote / IntentQuery: chroma/RAG deferred (no-op; reply says so). // - Clarify: the router's stage-3 fired; no action. // // Returns "" when the Replier should phrase the reply (the default path); // returns a non-empty string when the action path wants to OVERRIDE the // reply text (e.g. an action error the user should hear SPECIFICALLY, not // a generic "ok"). Errors surface as a short reply text the user hears. // queryMinScore — the note-recall confidence gate. Top cosine below this ⇒ // "no note" instead of a guess. Hand-tuned for the ONNX embedder; a knob, not // load-bearing math (same posture as the presence thresholds). const queryMinScore = 0.55 func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) string { if dec.Clarify { return "" // the Replier phrases clarify } switch dec.Intent { case router.IntentFact: if !dec.Slots.HasKey { return "не разобрала, что записать — попробуй иначе." } now := h.now() req := ipc.WriteFactReq{ Ts: now, Kind: "self", Key: dec.Slots.Key, Value: dec.Slots.Value, Source: "tap:voice", Confidence: 1.0, } if _, err := h.api.WriteFact(ctx, req); err != nil { log.Printf("voice: write fact: %v", err) return "не получилось сохранить факт." } return "" // replier phrases the success reply case router.IntentReminder: if !dec.Slots.HasTime { return "не получилось разобрать время напоминания." } payload := `{"text":` + jsonString(dec.Utterance) + `}` if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { log.Printf("voice: create reminder: %v", err) return "не получилось поставить напоминание." } return "" case router.IntentAct: // tool executor: run the matched fn against the enabled allowlist. // HasFn=false ⇒ no allowlist match: scaffold a 'proposed' tool the user // can enable on the authed surface ("earn the right to ask"). if !dec.Slots.HasFn { return h.proposeGap(ctx, dec) } out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false) if err != nil { switch { case errors.Is(err, tool.ErrNeedsConfirm): // destructive: park it and ask. The next utterance answers. phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args) h.park(dec.Slots.Fn, dec.Slots.Args, phrase) return "выполнить «" + phrase + "»? скажи «да» или «нет»." case errors.Is(err, tool.ErrNotEnabled): return h.proposeGap(ctx, dec) } log.Printf("voice: tool %s: %v", dec.Slots.Fn, err) if out != "" { return "не получилось выполнить команду: " + firstLine(out) } return "не получилось выполнить команду." } if out != "" { return "готово: " + firstLine(out) } return "готово." case router.IntentSystem: // System-status queries return to the Replier for phrasing. // The handler emits the current answer inline (no DB / RAG needed). return h.replySystem(ctx, dec) case router.IntentNote: // embed the note text with the same model the classifier uses, persist // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not // facts — no predicate reads it (spec's two-memory split). vec, err := h.embedder.Embed(ctx, dec.Utterance) if err != nil { log.Printf("voice: embed note: %v", err) return "не получилось сохранить заметку." } noteID, err := h.api.WriteNote(ctx, h.now(), 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) if h.memStore != nil { if err := h.memStore.Insert(ctx, strconv.FormatInt(noteID, 10), vec, map[string]string{ "source": "voice", }); err != nil { log.Printf("voice: memory insert: %v", err) } } return "" // replier phrases the "saved" reply case router.IntentQuery: // 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 < queryMinScore { // 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 "" } 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() 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 "пока не умею отвечать на этот вопрос." } } // reply wraps a text reply through TTS to produce a PushToTalkResp. If TTS // fails, the response carries an empty audio + the text — the client can // still display text if it can't play. The routedChannels field is // reserved for a future "the dispatcher also forwarded to ntfy/telegram" // reply (today the reactive path doesn't dispatch nudges; that's the loop // tick's job). func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) { log.Printf("voice: reply → %q", text) audioOut, err := h.tts.Synthesize(ctx, text) if err != nil { log.Printf("voice: tts error: %v", err) return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audio.Audio{}}, nil } return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil } // buildRouter constructs the reactive-path router with the given embedder // and confidence threshold. // - stage-0 grammars from DefaultActMatcher whose fn allowlist is exactly // the enabled tool names (actFns) — the router only matches acts the // executor can run. Empty ⇒ every act refuses at the matcher. // - The embedder is provided by wireVoice: HashEmbedder (floor) when no // embedder config is present, or the ONNX multilingual model when // configured — same interface, one constructor change. // - 6 bootstrap examples covering the 5 intents + one compound-capture // placeholder. Spec calls for ~10 per intent at production; this is the // bootstrapping floor swapped by tuning the seed set later. // - Threshold is from voice.router_threshold config (default 0.35). func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64) *router.Router { cls := router.NewClassifier(emb) seedClassifier(cls) return router.New(router.Config{ Grammars: router.DefaultGrammars(acts), Classifier: cls, Extractor: router.Extractor{ Time: router.StubDateTimeParser{}, Acts: acts, Facts: router.DefaultFactParser{}, }, Threshold: threshold, }) } // seedDir is the directory containing intent seed files. Each file is named // .txt and contains one training example per line (blank lines and // lines starting with # are ignored). Relative to the working directory. const seedDir = "models/seeds" // seedClassifier floors the embedded examples so the cold-boot path // doesn't return ErrNoIntents. Loads examples from seedDir — one file per // intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the // classifier can't decide it falls through to Clarify — the last-resort // path asks the user to rephrase rather than guessing wrong. func seedClassifier(c *router.Classifier) { intents := []router.Intent{ router.IntentAct, router.IntentReminder, router.IntentFact, router.IntentNote, router.IntentQuery, router.IntentSystem, } total := 0 for _, intent := range intents { n, err := loadSeedFile(c, intent) if err != nil { log.Printf("voice: seed %s: %v", intent, err) continue } total += n } log.Printf("voice: loaded %d seed examples from %s", total, seedDir) } func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) { path := filepath.Join(seedDir, string(intent)+".txt") f, err := os.Open(path) if err != nil { return 0, fmt.Errorf("open %s: %w", path, err) } defer f.Close() var count int sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } if err := c.AddExample(context.Background(), intent, line); err != nil { log.Printf("voice: seed %s: skipping %q: %v", intent, line, err) continue } count++ } if err := sc.Err(); err != nil { return count, fmt.Errorf("scan %s: %w", path, err) } return count, nil } // jsonString — a one-line JSON string encoder without dragging encoding/json // into the top of this file. Used to wrap a reminder payload's text field; // the router's reminder Slots are already absolute (DateTimeParser resolved // relative→absolute), the payload shape is conventional {"text":...}. func jsonString(s string) string { return jsonStringImpl(s) } // park stores a destructive act awaiting confirmation. Overwrites any prior // pending (last-asked wins — single-user box). func (h *reactiveHandler) park(fn string, args []string, phrase string) { h.mu.Lock() h.pending = &pendingAct{fn: fn, args: args, phrase: phrase, expiry: h.now().Add(confirmTTL)} h.mu.Unlock() } // resolveConfirm interprets an utterance as the answer to a parked destructive // act. Returns (reply, true) when it consumed the utterance as a y/n answer; // (\"\", false) when there's nothing pending (or the parked act expired), so the // caller routes the utterance normally. An unrecognised answer cancels the // pending act and routes normally — a confirm that can't be answered clearly is // safer abandoned than left armed. func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) { h.mu.Lock() p := h.pending if p == nil { h.mu.Unlock() return "", false } if h.now().After(p.expiry) { h.pending = nil h.mu.Unlock() return "", false } switch classifyConfirm(text) { case confirmYes: h.pending = nil h.mu.Unlock() out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed if err != nil { log.Printf("voice: tool %s (confirmed): %v", p.fn, err) if out != "" { return "не получилось выполнить команду: " + firstLine(out), true } return "не получилось выполнить команду.", true } if out != "" { return "готово: " + firstLine(out), true } return "готово.", true case confirmNo: h.pending = nil h.mu.Unlock() return "отменила.", true default: // unclear answer: abandon the confirm, route this utterance normally. h.pending = nil h.mu.Unlock() return "", false } } // proposeGap scaffolds a 'proposed' tool for an act whose verb isn't enabled. // maven drafts the registration (name = the verb, provenance = the utterance); // a human enables it on the authed surface. She suggests, never enables. func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string { name := firstWord(stripWake(dec.Utterance)) if name == "" { return "не разобрала команду — попробуй иначе." } newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now()) if err != nil { log.Printf("voice: propose tool %q: %v", name, err) return "команды «" + name + "» нет в списке разрешённых." } if newly { return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент." } return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент." } // confirmVerdict — the parse of a y/n confirm answer. type confirmVerdict int const ( confirmUnknown confirmVerdict = iota confirmYes confirmNo ) // classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the // stems so inflections/fillers ("да, давай", "нет, отмени") still land. func classifyConfirm(text string) confirmVerdict { t := strings.ToLower(strings.TrimSpace(text)) // negatives first — "не надо" contains no "да", but check no-stems before // yes so a leading "нет" isn't shadowed. for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} { if strings.Contains(t, no) { return confirmNo } } for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} { if strings.Contains(t, yes) { return confirmYes } } return confirmUnknown } // actPhrase renders "fn arg1 arg2" for the confirm prompt. func actPhrase(fn string, args []string) string { if len(args) == 0 { return fn } return fn + " " + strings.Join(args, " ") } // stripWake removes a leading "maven," wake token so the verb is the first word. func stripWake(u string) string { u = strings.TrimSpace(u) low := strings.ToLower(u) if strings.HasPrefix(low, "maven") { u = strings.TrimSpace(u[len("maven"):]) u = strings.TrimLeft(u, ",:; ") } return u } // firstWord returns the first whitespace-delimited token (lowercased) — the // proposed tool's name. func firstWord(s string) string { f := strings.Fields(s) if len(f) == 0 { return "" } return strings.ToLower(f[0]) } // seedTools upserts the config-declared tools into the store as enabled. Editing // mavend.json is a human act, so a config tool is enabled by definition; this // makes the declarative config the reproducible bootstrap while the store stays // the single runtime source of truth (mavweb enables ad-hoc ones on top). func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) { ctx := context.Background() now := time.Now() n := 0 for _, tc := range tools { if tc.Name == "" || len(tc.Cmd) == 0 { log.Printf("voice: skipping malformed tool config %+v", tc) continue } if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, 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" } 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) }