package main import ( "context" "log" "slices" "sort" "strings" "time" "unicode" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/phraser" "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 // correlationID — the id the proposing turn minted for this action. A // confirm arrives on a later turn with a context of its own, so without // carrying it here the execution recorded a fresh id and no causation at // all, and the resolve, the discovery and the thing they authorised sat in // the trace as unrelated calls. The contract mints one id per action, and // the action began when she asked. correlationID string } // 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 utterance that is not clearly yes or no is not an answer at all, so it is // handed straight back and the pending stays parked until it expires (V-567). // This resolver runs before routing and holds the most dangerous trigger on the // box; it may only claim a turn it is certain about. func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) { verdict := classifyConfirm(text) if verdict == confirmUnknown { return "", false } h.mu.Lock() defer h.mu.Unlock() for _, r := range h.confirmResolvers(ctx) { if !r.claim() { continue } // The slot is already cleared by claim(). switch verdict { case confirmYes: return r.yes(), true default: return r.no(), true } } return "", false } // confirmResolver — one parked-confirm slot in the chain. claim() reports // whether this slot holds a live pending, taking it (and dropping an expired // one) as it goes; yes/no then run the answer. Only ever called with h.mu held. type confirmResolver struct { claim func() bool yes func() string no func() string } // confirmResolvers builds the ordered chain resolveConfirm walks. Order is // deliberate: the routine proposal is checked before the tool confirm so a // routine confirm doesn't get eaten by a stale tool pending. func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolver { var pr *pendingRoutineConfirm var hx *pendingHexisExec var p *pendingAct return []confirmResolver{ // Routine proposal. { claim: func() bool { pr, h.pendingRoutine = h.pendingRoutine, nil return pr != nil && !h.now().After(pr.expiry) }, yes: func() string { // Voice does NOT accept (Vikunja #367). Accepting hands the // tick loop a standing new reason to speak, which is the same // tier as enabling a tool — and DESIGN.md § "surface caps // authority" says a room mic, reachable by anyone present, is // structurally incapable of layer 3. So a spoken "да" leaves // the row 'proposed' and points at the authed page, where the // accept button is gated at step-up. The convenience of // answering out loud stays; the authority does not move. // // Acceptance itself is recorded by /routines, and the tick // loop nudges on the interval from there (Vikunja #366). return phraser.C(phraser.ConfirmRoutineAuthed, nil) }, no: func() string { if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { log.Printf("voice: dismiss proposed routine: %v", err) } return phraser.C(phraser.ConfirmRoutineNo, nil) }, }, // Hexis execution confirm. Bound to the exact capability + target that // was proposed; a stray "да" can only run that, nothing else. { claim: func() bool { hx, h.pendingHexis = h.pendingHexis, nil return hx != nil && !h.now().After(hx.expiry) }, yes: func() string { execCtx := ctx if hx.correlationID != "" { execCtx = withCorrelationID(execCtx, hx.correlationID) } return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName) }, no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) }, }, // Tool confirm. { claim: func() bool { p, h.pending = h.pending, nil return p != nil && !h.now().After(p.expiry) }, yes: func() string { 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 phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)}) } return phraser.A(phraser.ActFail, nil) } if out != "" { return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)}) } return phraser.A(phraser.ActDone, nil) }, no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) }, }, } } // 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 phraser.C(phraser.ProposeNoVerb, nil) } vars := map[string]string{"name": name} newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now()) if err != nil { log.Printf("voice: propose tool %q: %v", name, err) return phraser.C(phraser.ProposeFailed, vars) } if newly { return phraser.C(phraser.ProposeNew, vars) } return phraser.C(phraser.ProposeAlready, vars) } // confirmVerdict — the parse of a y/n confirm answer. type confirmVerdict int const ( confirmUnknown confirmVerdict = iota confirmYes confirmNo ) // confirmWords are the two closed sets, tokenized once and ordered // longest-first so "не надо" is read before "нет" could claim any of it. var ( confirmYesPhrases = confirmPhrases(lexicon.ConfirmYes()) confirmNoPhrases = confirmPhrases(lexicon.ConfirmNo()) ) // confirmPhrases splits each lexicon member into tokens and sorts the result // longest-first, so a walk that tries them in order matches the longest member // that fits. func confirmPhrases(words []string) [][]string { out := make([][]string, 0, len(words)) for _, w := range words { if toks := confirmTokens(w); len(toks) > 0 { out = append(out, toks) } } sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) }) return out } // confirmTokens splits an utterance into lowercase word tokens. Punctuation and // spacing are separators; an apostrophe is not, because "don't" is one word. func confirmTokens(text string) []string { return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { if r == '\'' || r == '’' { return false } return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) } // classifyConfirm reads a short ru/en yes-or-no answer to a parked confirm. // // The whole utterance must consist of confirmation words and filler, matched as // whole tokens against the closed lexicon sets. Anything else is // confirmUnknown, which leaves the confirm parked and routes the turn — see // resolveConfirm. Both halves of that are the fix for V-567: this used to be a // substring test over bare stems, so "погода", "дальше", "надо" and "давление" // all read as "да", and "покажи" and "около" read as "ок". A parked destructive // act fired on a question about the weather. // // Requiring the WHOLE utterance is the second half. A leading confirm word does // not make a sentence an answer: "давай посмотрим погоду" opens a request, and // the only safe reading of a sentence that carries its own subject is that he // moved on. Guessing wrong here executes something; guessing wrong the other way // asks again. func classifyConfirm(text string) confirmVerdict { tokens := confirmTokens(text) if len(tokens) == 0 { return confirmUnknown } verdict := confirmUnknown for i := 0; i < len(tokens); { // Negatives first: "не надо" and "не хочу" open with a token that is // not itself an answer, and a yes hit must never shadow them. if n := matchConfirm(confirmNoPhrases, tokens[i:]); n > 0 { return confirmNo } if n := matchConfirm(confirmYesPhrases, tokens[i:]); n > 0 { verdict, i = confirmYes, i+n continue } if lexicon.IsFillerParticle(tokens[i]) { i++ continue } // A word that is neither an answer nor filler carries a subject of its // own, so this utterance is not an answer to her question. return confirmUnknown } return verdict } // matchConfirm reports the length of the longest phrase matching at the head of // tokens, or 0. func matchConfirm(phrases [][]string, tokens []string) int { for _, p := range phrases { if len(p) > len(tokens) { continue } if slices.Equal(p, tokens[:len(p)]) { return len(p) } } return 0 } // 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, " ") }