package dialogue import ( "sync" "time" ) // Slot names one field of Slots. Named type, not a free string, so a missing // slot cannot be misspelled — the question phrasing switches on these. type Slot string const ( SlotTime Slot = "time" // Slots.Time / HasTime SlotKey Slot = "key" // Slots.Key / HasKey SlotValue Slot = "value" // Slots.Value (paired with Key) SlotFn Slot = "fn" // Slots.Fn / HasFn SlotText Slot = "text" // Slots.Text ) // DefaultMaxAttempts — how many questions she may ask about one request. // Three, because after three tries the likely problem is that she misheard the // whole request, not one slot — so another question about that slot won't help. // Configurable: voice.clarify_max_attempts. const DefaultMaxAttempts = 3 // PendingQuestion is what Maven holds while she waits for an answer to an open // question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here // is free text that fills a missing slot rather than a verdict. type PendingQuestion struct { Intent Intent // what the router already guessed Slots Slots // what it already filled Missing []Slot // what is still empty, in the order to ask about Utterance string // the user's original raw words // WhenText is every answer he has given about the time, joined in the order // he gave them. Kept apart from Utterance because the utterance is the // reminder's payload, and because a time answer has to be read against the // request rather than alone: "завтра" names a day for an hour said earlier. WhenText string Asked time.Time TTL time.Duration Attempts int // questions already asked // MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts. MaxAttempts int // Suspends counts how many times this question has stepped aside for // something he asked instead, and come back on the end of the answer. It is // deliberately NOT an attempt: a side query is not a failed answer, and // charging it a retry is the V-554 shape. See CanResume for why it is // counted at all. Suspends int // Rides counts every turn this question has ridden out on the end of // someone else's reply, over the whole life of the request. Unlike Suspends // it is never reset and never re-based, which is the only property that // matters about it (V-663). Rides int } // MaxSuspends — how many times one question may step aside and come back before // she lets the request go (V-654). // // It exists because suspension had no bound of any kind. A side query spends no // attempt, so MaxAttempts never applies to it, and it restarts the 90s clock, so // the TTL never arrives either. Measured on 2026-08-07: one unfilled time slot // rode the end of six consecutive unrelated replies and stopped only when a // seventh turn happened to read as a failed answer. // // Three, matching DefaultMaxAttempts, and for the same reason. Once he has // asked for three other things without touching the question, the likely truth // is that he has moved on and has not said so. const MaxSuspends = 3 // MaxRides — how many turns one question may ride out on the end of an // unrelated reply, counted over its whole life (V-663). // // MaxSuspends did not move the measurement it was written for. Twenty-six of // 140 turns carried a tail before it landed and twenty-six carried one after. // Every bound on this question is rearmed by something ordinary: // // - The TTL is an inactivity timer, and both noteSuspended and reaskOrGiveUp // restart it, so it cannot arrive while he keeps talking. // - Suspends is zeroed by any turn that reads as an answer, which is where // "спасибо" and "привет" land. It resets before anything is known to have // been filled. // - askRemainingGap builds a fresh question for the second gap, so a reminder // with two gaps gets a new allowance halfway through. // // So Suspends only bites on four strictly consecutive side queries with nothing // chat-like between them, which is not the shape real conversation has. Rides is // the same idea with the resets taken out: set once, incremented, carried // across a re-park, and read by nothing that could lower it. // // The shape it is aimed at is measured, not imagined. In the 2026-08-08 run one // question about a reminder's day rode turns 7 to 13 and ended only because // turn 14 was a new request. Three asides, then two turns that read as failed // answers, then two more asides. The asides spend no attempt and the answers // reset Suspends, so the two bounds take turns being rearmed by the other's // traffic. // // Four, not three. It has to be looser than MaxSuspends or that bound is dead // code, because Rides is never lower than Suspends and would always fire first. // // Do not read this as a fix for the whole ride. It ends the measured one a turn // early and no more. Most of that ride's length is attempts, spent by turns // like "спасибо" and "привет" being read as failed answers to a question about // a day. That is a defect in classifyTurnRole and not in any bound here. const MaxRides = 4 // CanResume reports whether this question may step aside once more. False ⇒ the // caller lets the request go and says so; it must never simply stop resuming, // because a question dropped in silence reads as one that was answered. // // Two bounds, and they answer different questions. Suspends asks whether he has // walked away from this exchange in the last few turns. Rides asks whether this // question has been riding long enough that the answer is no regardless. func (q *PendingQuestion) CanResume() bool { return q.Suspends < MaxSuspends && q.Rides < MaxRides } // Action reads the parked question as the typed action it is assembling // (pending.go). Derived rather than stored: the question's fields stay the one // copy of the truth, so a caller that fills them the old way cannot end up with // a capability that disagrees with the intent. func (q *PendingQuestion) Action() PendingAction { return PendingAction{ Capability: CapabilityFor(q.Intent), Slots: q.Slots, Missing: q.Missing, Utterance: q.Utterance, Asked: q.Asked, TTL: q.TTL, Attempts: q.Attempts, MaxAttempts: q.MaxAttempts, } } // IsExpired and CanAsk answer through the action, so there is exactly one copy // of the TTL and attempt-cap rules and the widening cannot drift from them. func (q *PendingQuestion) IsExpired(now time.Time) bool { a := q.Action() return a.IsExpired(now) } // CanAsk reports whether Maven may ask another question about this request. func (q *PendingQuestion) CanAsk() bool { a := q.Action() return a.CanAsk() } // ClarifyStore holds the parked questions. Same shape and locking as // SessionStore: keyed by dialogue id, expired entries dropped on read. // // Memory only, deliberately, unlike SessionStore — a restart expires every // parked question and she does not announce that it happened (Vikunja #385, // written down in docs/design.md). The 90s TTL and the attempt count measure a // pause in one conversation, and a restart is a gap of unknown length, so a // restored question would either be dead already or lying about its age. His // next words route fresh, which is the right answer with or without a notice. // Do not give this store a persister without re-arguing that. type ClarifyStore struct { mu sync.RWMutex // stacks — one stack of parked questions per dialogue id, newest last. It // was a single question per id until V-559; a side query has to be able to // suspend the active flow and find it still there afterwards (V-561 does // the suspending, this only holds the room for it). stacks map[string][]*PendingQuestion defaultTTL time.Duration } // MaxStackDepth — how many parked questions one dialogue id may hold. // // Two, not three. One is the flow he is in, one is the thing he interrupted it // with, and out loud he does not nest deeper than that: a side query inside a // side query is a shape typed conversation has and spoken conversation does // not. The bound is also a promise — every level she keeps is a level she must // be able to SPEAK when it dies (clarifyGaveUp, clarifyExpiredVariants), and // two lines of "and the other thing I dropped" is already the limit of what a // reply can carry. const MaxStackDepth = 2 // DefaultClarifyTTL — how long a parked question stays his answer to give. // Short, like confirmTTL in voice.go: a clarifying question is a same-breath // gesture, and a stale one should not eat a later utterance. const DefaultClarifyTTL = 90 * time.Second func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore { if defaultTTL <= 0 { defaultTTL = DefaultClarifyTTL } return &ClarifyStore{ stacks: make(map[string][]*PendingQuestion), defaultTTL: defaultTTL, } } // Put parks a question, replacing the one on top. Called on a clarify decision // (cmd/mavend/clarify.go), and it is still what the daemon uses: re-asking the // same request is a new question about the SAME action, so it overwrites rather // than growing the stack. Push is the deeper one, and nothing calls it yet. func (s *ClarifyStore) Put(id string, q *PendingQuestion) { s.fillTTL(q) s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 { s.stacks[id] = []*PendingQuestion{q} return } stack[len(stack)-1] = q } // Push suspends whatever is parked and puts q on top. The returned question is // one the depth bound forced out of the bottom of the stack, and the caller MUST // tell him about it — a parked request that dies without a word leaves him // thinking it landed, which is the whole reason clarifyGaveUp exists. nil is the // ordinary case. func (s *ClarifyStore) Push(id string, q *PendingQuestion) *PendingQuestion { s.fillTTL(q) s.mu.Lock() defer s.mu.Unlock() stack := append(s.stacks[id], q) var dropped *PendingQuestion if len(stack) > MaxStackDepth { dropped = stack[0] stack = stack[1:] } s.stacks[id] = stack return dropped } // Peek returns the live question on top, or nil when there is none. An expired // top takes the whole stack with it, exactly as Pop does: the clock that killed // it has been running for everything underneath too. // // That drop is silent, which is the death this store is otherwise careful // about, so TakeExpired has to run BEFORE Peek on a turn — it is what counts the // dropped questions and tells him they are gone. cmd/mavend/voice.go calls // clarifyExpiredNotice first for that reason, and reordering the two makes the // notice unreachable rather than wrong. func (s *ClarifyStore) Peek(id string, now time.Time) *PendingQuestion { s.mu.RLock() stack := s.stacks[id] var q *PendingQuestion if len(stack) > 0 { q = stack[len(stack)-1] } s.mu.RUnlock() if q == nil { return nil } if q.IsExpired(now) { s.Delete(id) return nil } return q } // Get is Peek under the name every caller already uses. Kept because a clarify // answer is always about the top of the stack, so the two are the same call. func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { return s.Peek(id, now) } // Pop takes the live question off the top and returns it, so the flow beneath // becomes current again. nil when the top is empty or expired — an expired top // is dropped along with the rest of the stack, exactly as Peek does, because the // clock that killed it has been running for everything underneath too. func (s *ClarifyStore) Pop(id string, now time.Time) *PendingQuestion { s.mu.Lock() stack := s.stacks[id] if len(stack) == 0 { s.mu.Unlock() return nil } q := stack[len(stack)-1] if q.IsExpired(now) { delete(s.stacks, id) s.mu.Unlock() return nil } if len(stack) == 1 { delete(s.stacks, id) } else { s.stacks[id] = stack[:len(stack)-1] } s.mu.Unlock() return q } // CompleteTop removes the live question being completed and returns the flow // that was suspended underneath it, if any. The returned question stays parked; // callers use it only to make that surviving state audible again. A stale top // expires the whole stack, matching Peek and Pop. func (s *ClarifyStore) CompleteTop(id string, now time.Time) (completed, resumed *PendingQuestion) { s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 { return nil, nil } completed = stack[len(stack)-1] if completed.IsExpired(now) { delete(s.stacks, id) return nil, nil } if len(stack) == 1 { delete(s.stacks, id) return completed, nil } stack = stack[:len(stack)-1] s.stacks[id] = stack resumed = stack[len(stack)-1] // The surviving flow is spoken again now, so its answer window starts now. // A completed nested request also ends the run of asides around it; Rides is // deliberately retained as the lifetime bound for this flow. resumed.Asked = now resumed.Suspends = 0 return completed, resumed } // Depth — how many questions are parked for this id, expired ones included. // Diagnostic; the arbiter in V-560 reads it to know it is inside a flow. func (s *ClarifyStore) Depth(id string) int { s.mu.RLock() defer s.mu.RUnlock() return len(s.stacks[id]) } // TakeExpired reports HOW MANY parked questions were dropped because the TTL // ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still // live. Get drops such a question silently, which leaves the user thinking his // request is still alive — the caller uses this to tell him it is gone before // treating his words as a fresh utterance. // // It looks at the top only, and drops the whole stack when that one is dead: // anything parked under a question that timed out has been waiting at least as // long. The COUNT rather than a bool since V-561, because the stack can now // hold two — the flow and the side query that suspended it — and a notice // saying "прошлую просьбу" when two died is a lie about the count. func (s *ClarifyStore) TakeExpired(id string, now time.Time) int { s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) { return 0 } delete(s.stacks, id) return len(stack) } // Delete drops every question parked for this id. The old single-slot Delete // under the old name: at depth one the two are the same, and a caller that means // "this exchange is over" means all of it. func (s *ClarifyStore) Delete(id string) { s.mu.Lock() delete(s.stacks, id) s.mu.Unlock() } // fillTTL applies the store default to a question parked without one. func (s *ClarifyStore) fillTTL(q *PendingQuestion) { if q.TTL <= 0 { q.TTL = s.defaultTTL } } // Answer merges the slots parsed from the user's answer into the parked ones. // Only the slots listed in Missing are touched. Within those, a value the answer // carries WINS over what was parked: she asked about this slot, so «нет, в пять» // after «в три» must replace the time, not be thrown away. // // This is the clarify answer only. A correction in a fresh turn ("вообще-то // перенеси на пять") is a different code path (followUpMerge) — not here. // // Parsing the answer text into `answer` is the caller's job; this package must // stay free of internal/router. func (q *PendingQuestion) Answer(text string, answer Slots) Slots { out := q.Slots for _, slot := range q.Missing { switch slot { case SlotTime: if answer.HasTime { out.Time = answer.Time out.HasTime = true } case SlotKey: if answer.HasKey { out.Key = answer.Key out.HasKey = true } case SlotValue: if answer.Value != "" { out.Value = answer.Value } case SlotFn: if answer.HasFn { out.Fn = answer.Fn out.HasFn = true out.Args = append([]string(nil), answer.Args...) } case SlotText: if answer.Text != "" { out.Text = answer.Text } else if out.Text == "" { // No parse for a text slot — the raw answer IS the text. out.Text = text } } } return out } // StillMissing lists the slots that are empty in s, out of the ones asked for. // The caller uses it to decide between acting and dropping the request. func StillMissing(want []Slot, s Slots) []Slot { var out []Slot for _, slot := range want { empty := false switch slot { case SlotTime: empty = !s.HasTime case SlotKey: empty = !s.HasKey case SlotValue: empty = s.Value == "" case SlotFn: empty = !s.HasFn case SlotText: empty = s.Text == "" } if empty { out = append(out, slot) } } return out }