package main import ( "context" "log" "strings" "time" "github.com/kami/maven/internal/router" ) // 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 // 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 // Only record the acceptance. The tick loop reads accepted // routines and nudges on their own interval. Building a reminder // here made a routine fire exactly once (Vikunja #366). if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { log.Printf("voice: accept proposed routine: %v", err) return "не получилось запомнить рутину.", true } 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, " ") }