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() for _, r := range h.confirmResolvers(ctx) { if !r.claim() { continue } // The slot is already cleared by claim(): every branch below drops the // pending, including the unclear one — a confirm that can't be // answered clearly is safer abandoned than left armed. switch classifyConfirm(text) { case confirmYes: return r.yes(), true case confirmNo: return r.no(), true default: return "", false } } 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 { // 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 "не получилось запомнить рутину." } return "буду напоминать." }, no: func() string { if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { log.Printf("voice: dismiss proposed routine: %v", err) } return "хорошо, не буду." }, }, // 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 { return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName) }, no: func() string { return "отменила." }, }, // 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 "не получилось выполнить команду: " + firstLine(out) } return "не получилось выполнить команду." } if out != "" { return "готово: " + firstLine(out) } return "готово." }, no: func() string { return "отменила." }, }, } } // 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, " ") }