package main import ( "context" "errors" "fmt" "log" "regexp" "strings" "time" "github.com/kami/maven/internal/crawl" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/rss" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/weather" ) // queryTurn is the per-turn scratch a chain of query sources shares: the // decision being answered plus the work an earlier source already paid for // (the query embedding, the notes it pulled). Sources read and fill it in // order, so a later source never re-embeds. type queryTurn struct { dec router.Decision vec []float32 notes []ipc.Note } // querySource — one answer source in the chain actionQuery walks. answer // returns (reply, true) when this source claims the question, ("", false) // when it passes to the next one. name is for reading the table, not logged. // // A struct of one func rather than an interface: every source is a plain // method on *reactiveHandler with no state of its own (what state a turn has // lives in queryTurn), so an interface would mean one empty type per source // to satisfy it — ceremony for nothing. Same reasoning as confirmResolver in // confirm.go, and the table then reads like actionHandlers: a flat list of // method expressions you extend with one line. type querySource struct { name string answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool) // dateAware — this source reads the day out of the turn and answers for // THAT day. Only such a source may claim a continuation ("а завтра?"), // because a continuation is a question about a different day and nothing // else. A date-blind source claiming one would answer with today's data // under tomorrow's question, which is a wrong answer delivered in a // confident voice — the failure mode that took reminder out of // continuableIntents (continuation.go). // // Exactly one source qualifies today, and that is not an oversight in the // table: CalendarEvents is the only CoreAPI call that takes a date at all. // DayPlan is today-only, CurrentWeather is now-only, and the recall // sources search text with no notion of a day. When one of them grows a // date parameter, flip its flag here. dateAware bool } // querySources is the ordered chain actionQuery walks; first source to claim // answers the turn. THE ORDER IS LOAD-BEARING — see the memory-before-notes // comment on queryMemory: running the notes-only pass first was #373, and the // gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one // line here plus its method; where you put the line is the whole decision. var querySources = []querySource{ {name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey}, // Before "calendar" on purpose: both match "…на сегодня", and the plan is // the more specific ask (its matcher requires a plan word), so the calendar // listing would otherwise swallow it. {name: "day-plan", answer: (*reactiveHandler).queryDayPlan}, // Also before "calendar": "что я обычно делаю по средам?" names a weekday, // and the habit question is the more specific one. Its matcher requires a // habit marker ("обычно", "каждый", …), so a question about this coming // Wednesday still reaches the calendar. {name: "habits", answer: (*reactiveHandler).queryHabits}, // Before "calendar" and before the recall sources: "что мне нужно // сделать?" is a question about the task list, and the notes pass would // otherwise answer it with whatever note happens to be nearest. Its // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. {name: "tasks", answer: (*reactiveHandler).queryTasks}, // Next to "tasks" and for the same reason: "что требует внимания?" is a // question about the operational state Praxis holds, and it used to fall // through every source to the web search (Vikunja #475). Its matcher needs // an attention marker, and it falls through when Praxis is not configured. {name: "attention", answer: (*reactiveHandler).queryAttention}, // Before the recall sources too: "сколько я потратил?" is a question about // the money facts the poller wrote, and the notes pass would otherwise // answer it from whatever he once said about spending. Its matcher needs a // money noun plus an actual ask, so "я потратил весь день" is untouched. // Next to "tasks" and for the same reason: "что мне купить?" is a question // about the shopping list, and the recall pass would otherwise answer it // from an old note about the shop. Its matcher needs an explicit list // marker, so "надо бы съездить в магазин" is untouched. {name: "list", answer: (*reactiveHandler).queryList}, {name: "money", answer: (*reactiveHandler).queryMoney}, // Also above the recall sources: "что я тебе говорил?" is a question about // the facts he tapped in, and the notes pass would answer it with whatever // note is nearest (Vikunja #456). Its matcher needs both halves of a // history phrase and bails out when he names a topic, so "что я говорил // про сервер" is still recall. {name: "history", answer: (*reactiveHandler).queryHistory}, // Before the recall sources and before general knowledge: "что нового?" is // a question about the feeds she reads, and general knowledge would answer // it by inventing news. Its matcher needs a feed noun plus an ask, so // "у меня новая лента в инстаграме" is untouched. {name: "feeds", answer: (*reactiveHandler).queryFeeds}, // Before "calendar" and before the recall sources: "что включено дома?" is // a question about the house, and the notes pass would otherwise answer it // from whatever he once said about the lights. Its matcher needs a house // marker plus an ask plus a device word, and it bails out on weather // wording, so "какая температура на улице?" still reaches the weather // source. {name: "home", answer: (*reactiveHandler).queryHome}, // Next to "home" and for the same reason: "какие устройства в сети?" is a // question about the LAN, and the recall pass would otherwise answer it // from an old note about the router. Its matcher needs a network word plus // an ask plus a device noun, so "интернет не работает" is untouched. {name: "network", answer: (*reactiveHandler).queryNetwork}, {name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true}, {name: "weather", answer: (*reactiveHandler).queryWeather}, {name: "embed", answer: (*reactiveHandler).queryEmbed}, {name: "memory", answer: (*reactiveHandler).queryMemory}, {name: "notes", answer: (*reactiveHandler).queryNotes}, // THE BOUNDARY. Everything above answers from his own data; everything // below answers from the world's. A question about him that got this far // has no answer in his data, and no outside source can supply one, so this // stops the walk rather than let the encyclopedia and the model guess. {name: "personal", answer: (*reactiveHandler).queryPersonal}, // The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats // a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at // stake by this point — the boundary above already stopped every question // about him, and only the query string leaves the box. {name: "search", answer: (*reactiveHandler).querySearch}, // The offline encyclopedia, now the fallback for when the line is down or // the search comes back empty. It reads the way it always did; what changed // is that it no longer gets first refusal on a world question. {name: "kiwix", answer: (*reactiveHandler).queryKiwix}, // LAST before the model answers from memory, and that position is the whole // design (Vikunja #259): everything of his, then the search, then the ZIMs, // and only then a page he named. The model does NOT come first: it // answers after this, because a URL he said out loud is an instruction and // a 1.7B guessing at a page it cannot read is how contents get invented. // This source only claims a turn where he named a URL, so it never competes // with a local answer. {name: "web", answer: (*reactiveHandler).queryWeb}, {name: "general-knowledge", answer: (*reactiveHandler).queryGeneral}, } func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { t := &queryTurn{dec: dec} for _, src := range querySources { if dec.Continued && !src.dateAware { continue } if reply, ok := src.answer(h, ctx, t); ok { // Which source claimed is the one thing about a query turn that was // invisible from outside: /trace is the nudge-rule trace and carries // no query-source field, so a wrong answer could not be told from a // wrongly-ordered chain (Vikunja #474). Only the name is logged — // the utterance and the answer are already on the voice lines above // and below this one. The same name goes to the turn's sink when the // caller asked for one, so /chat can show it (V-539). log.Printf("voice: query claimed by source %q", src.name) noteQuerySource(ctx, src.name) return reply } } if dec.Continued { // The previous question cannot be re-asked for another day. Saying so // beats "не знаю", which reads as "no data for tomorrow" when the // truth is that she never looked. return phraser.Q(phraser.QueryOtherDay, nil) } return phraser.Q(phraser.QueryUnknown, nil) } // queryFactByKey — when the dialogue layer resolved an anaphoric reference to // a prior fact's key (e.g. "когда я это сделал?" after "запиши что я пил // воду"), look up the fact's value directly. func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (string, bool) { dec := t.dec if !dec.Slots.HasKey || dec.Slots.Key == "" { return "", false } f, err := h.api.LatestFact(ctx, dec.Slots.Key) if err != nil { return "", false } if dec.Slots.HasTime { // The query asks about timing — the fact's own timestamp is the // answer it's looking for. Format as a natural reply. return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true } // General fact reference: describe what we know. if dec.Utterance == "" { return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true } // The utterance still carries the question; fall through to normal RAG // with the resolved key in context. return "", false } // queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что // дальше?" (Vikunja #128). Recites the day: calendar events, pending // reminders, and every morning checklist item today still has no evidence for, // including the ones whose window has closed. // // Read-only by construction — the plan is assembled and rendered core-side and // nothing here schedules or announces. "что дальше?" asks for the rest of the // day, so that phrasing trims what has already passed. // // What surface this belongs on is still open, tracked as Vikunja #431 ("Board // surface: Maven holds the work board, runs the intake form, never argues"). // The spoken recital here is the current answer, not the decided one. func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) { if !router.IsDayPlanQuery(t.dec.Utterance) { return "", false } plan, err := h.api.DayPlan(ctx) if err != nil { log.Printf("voice: day plan: %v", err) return phraser.Q(phraser.QueryFailPlan, nil), true } if !router.IsRestOfDayQuery(t.dec.Utterance) { return plan.Spoken, true } // Rebuild the pure plan so the rest-of-day rendering is the same code that // rendered the whole day — one formatter, one persona. p := morning.Plan{Date: plan.Date} for _, it := range plan.Items { p.Items = append(p.Items, morning.PlanEntry{ At: it.At, Text: it.Text, Kind: morning.PlanKind(it.Kind), Uncertain: it.Uncertain, }) } return p.After(h.now()).FormatRU(), true } // habitFactWindow — how many recent SELF facts the behaviour profile is counted // over. Enough for a season of habits without scanning the whole store on every // question; the profile is recomputed on read, so the bound is the cost control. // // The read is kind-filtered in SQL, and that is the load-bearing part. When this // was a plain recent-facts read the window was a row budget over every writer, // and the machine writers dwarf the taps: mavpoll writes a wg_handshake row // whenever a peer rehandshakes, which is roughly every two minutes per peer, so // 2000 rows was under three days of history. A weekday habit needs // memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so // she answered "по вторникам у меня пока нет ничего постоянного" forever on a // store with a year of taps in it. Self facts come from voice taps, and he does // not tap seven hundred times a day. const habitFactWindow = 2000 // queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the // answer out of the fact log rather than asking the model to summarise a life: // see internal/memory/behavior.go for why nothing here is generated. func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) { q, ok := router.ParseHabitQuery(t.dec.Utterance) if !ok { return "", false } facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow) if err != nil { log.Printf("voice: habits: recent facts: %v", err) return phraser.Q(phraser.QueryFailNotes, nil), true } obs := make([]memory.Observation, 0, len(facts)) for _, f := range facts { obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind}) } profile := memory.BuildProfile(obs, h.now()) if q.HasWeekday { return profile.FormatWeekdayRU(q.Weekday), true } if q.Weekend { return profile.FormatWeekendRU(), true } return profile.FormatOverallRU(), true } // feedNoteWindow — how many recent FEED notes are scanned, and // feedReadOut — how many headlines she actually reads back. She summarises the // top of the pile, she does not recite a river. const ( feedNoteWindow = 200 feedReadOut = 3 ) // queryFeeds — "что нового в лентах?", "что нового по технологиям?" // (Vikunja #258). // // This is the ONLY way a feed item reaches him. The poller writes notes and // never speaks; asking is the trigger. If that ever changes, the thing that // changed is "Maven is not a nag", not a detail of this file. func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) { q, ok := router.ParseFeedQuery(t.dec.Utterance) if !ok { return "", false } if !h.feedsOn { // Claim only when nothing below can read the world. The reason this // source used to claim unconditionally was that general knowledge would // answer "что нового?" with an invented news bulletin — true, and it // stopped being the only alternative on 2026-08-02, when live search // took the lead. With SearXNG or the ZIMs configured, "что происходит // в новостях про искусственный интеллект?" has a real answer below, // and a configuration status is the wrong thing to say instead // (Vikunja #474). if h.search != nil || h.kiwix != nil { return "", false } return phraser.Q(phraser.QueryFeedsOff, nil), true } // By source, not the last 200 notes of any kind: a busy day of voice notes // used to push the newest headline out of the window, and she answered "в // лентах пока ничего нового" while the poller was working fine. notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow) if err != nil { log.Printf("voice: feeds: recent notes: %v", err) return phraser.Q(phraser.QueryFailFeeds, nil), true } var picked []string for _, n := range notes { if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) { continue } // The note carries title, summary, category tag and link; she reads the // title alone. The tag is for the match above, and piper reads brackets // out loud. picked = append(picked, rss.NoteHeadline(n.Text)) if len(picked) == feedReadOut { break } } if len(picked) == 0 { if q.Category != "" { return phraser.Q(phraser.QueryFeedsTopic, nil), true } return phraser.Q(phraser.QueryFeedsEmpty, nil), true } return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true } // queryCalendar — "что у меня сегодня?", "планы на завтра?" // h.now(), not time.Now(): the handler's clock is the injected one, so this // source can be tested at a fixed time like the rest. func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) { // A day word is all this source matches on, so any question that merely // names a day reached it first. "какая сегодня погода в Москве?" answered // "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day // far more often than the calendar is, and the weather source sits right // below, so the calendar steps aside on weather wording — the same bail-out // queryHome already does for the same reason. if isWeatherQuery(t.dec.Utterance) { return "", false } date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now()) if !ok { return "", false } events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) return phraser.Q(phraser.QueryFailCalendar, nil), true } // Provenance travels with each event. A work meeting relayed off a phone // notification (source ambient:notif, #126) is stored below full confidence // and gets hedged; a CalDAV read is recited plainly. entries := make([]router.CalendarEntry, len(events)) for i, e := range events { entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0} } var f router.CalendarEventFormatter return f.FormatEntries(entries, date), true } // queryHome answers a question about the house. Read-only by construction: it // calls States and nothing else, so there is no confirm turn here — the only // way to CHANGE something is an enabled allowlist row through tool.Executor. func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) { if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) { return "", false } if h.home == nil { // Fall through rather than claim the turn. A capability that is off // must not change what an unconfigured box answers: "какая температура // в доме?" on a Maven with no smarthome block reached recall before // this source existed, and a stored fact is a better answer than // "дом не подключён" from a house that was never configured. The // unreachable case is different and homeSummary covers it. return "", false } ctxH, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() return h.home.homeSummary(ctxH) } // queryNetwork answers a question about the LAN with a bounded scan. There is // no confirm turn because nothing is changed, and no way to widen the range // because Scan takes no target — the utterance selects the question, never the // subnet. func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) { if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) { return "", false } if h.netscan == nil { // The recogniser already matched, so this is a question about HIS LAN // and there is no scanner to answer it. Falling through sent it to the // search leg, which answered with a paragraph about routers in general // and put his network question on an upstream engine (Vikunja #479). // A missing capability names itself. return phraser.Q(phraser.QueryNetOff, nil), true } return h.netscan.scanSummary(ctx) } func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) { return "", false } loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation) if loc == "" { // He named no city and voice.weather.default_location is unset. Saying // so is the only honest answer; picking a city would be inventing one. return phraser.Q(phraser.QueryWeatherWhere, nil), true } ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) if errors.Is(err, weather.ErrNotConfigured) { return phraser.Q(phraser.QueryWeatherOff, nil), true } if errors.Is(err, weather.ErrLocationUnknown) { // He named a place and the geocoder does not have it. Saying so beats // reading out the default city's temperature (Vikunja #421). return "не знаю такого города — " + loc + ".", true } if err != nil { log.Printf("voice: weather: %v", err) return phraser.Q(phraser.QueryFailWeather, nil), true } return phraser.Q(phraser.QueryWeatherNow, map[string]string{ "location": w.Location, "temp": fmt.Sprintf("%.0f", w.Temperature), "word": phraser.Degrees(w.Temperature), "condition": w.Condition, }), true } // queryEmbed isn't an answer source — it's the shared cost the two recall // sources below both need, run once, in the position it always ran in. It // only claims the turn when the embedder fails. func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) { vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) return phraser.Q(phraser.QueryFailAnswer, nil), true } t.vec = vec return "", false } // queryMemory — long-term memory first: ONE search over everything Maven // remembers (notes and facts share this index) and ONE confidence gate, so // the memory that is clearly the best match answers — a note just as much as // a fact. // // This used to run only after the notes-only source below had already // rejected the same note at the same score, which no note could ever survive // a second time: the branch could only return a fact (#373). Order, not the // gate, was the bug — the set of questions Maven answers is unchanged, only // which memory gets to answer them. func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) { if h.recall.memStore == nil { return "", false } hits, herr := h.recall.memStore.Search(ctx, t.vec, 3) if herr != nil { log.Printf("voice: memory search: %v", herr) return "", false } hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin) if !ok { return "", false } text := hit.Meta["text"] // The score cleared the gate and the topic still has to match (#470). A // note about his slow network scored high enough to answer "почему небо // синее?", because the right-note and must-be-silent score ranges overlap // and no threshold sits between them. if !memory.RecallAllowed(t.dec.Utterance, text) { log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance) return "", false } // A note is phrased in Maven's voice; a fact is read back as it was // stored. if hit.Meta["type"] == "note" { reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}) switch { case perr != nil: // Reading the note back verbatim beats the phraser's own fallback, // which only wraps the same text in "вот что я нашла:". log.Printf("voice: recall phrase: %v", perr) case reply != "": return reply, true } } return text, true } // queryNotes — notes-only pass, for notes the vector index above does not // hold (an older note written before it existed). Same gate, notes-only // candidates. // // Confidence gate: below it, 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. Two parts: an absolute cosine floor, and a margin over the // runner-up, which is the part that works with the e5 embedder's narrow score // band. See memory.Confident. Failing the gate passes the turn on to general // knowledge, which is what "don't read back the runner-up" means here. func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) { notes, err := h.api.QueryNotes(ctx, t.vec, 5) if err != nil { log.Printf("voice: query notes: %v", err) return phraser.Q(phraser.QueryFailAnswer, nil), true } t.notes = notes noteScores := make([]float64, len(notes)) for i, n := range notes { noteScores[i] = n.Score } if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) { return "", false } // Same topic veto as queryMemory above: the best note must be about what // he asked, not merely the nearest vector in the index. if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) { log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance) return "", false } texts := make([]string, len(notes)) for i, n := range notes { texts[i] = n.Text } reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, texts) if err != nil { log.Printf("voice: phrase query: %v", err) } if reply == "" { reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]}) } return reply, true } // webPageContextRunes — how much of a fetched page is handed to the phraser. // Less than the crawler keeps: the rest of the 4096-token window belongs to the // prompt, the persona block and the reply. const webPageContextRunes = 1500 // queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259). // // It claims a turn ONLY when he named a URL, which is what keeps a fallback from // becoming a habit: no URL, no fetch, and the model answers from what is local. // What leaves the box is the URL and nothing else — no note, no fact, no history // travels with it. func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) { link, ok := router.FirstURL(t.dec.Utterance) if !ok { return "", false } if h.crawler == nil { // He named a URL, so the question is about that page and nothing else // can answer it. The older comment here argued for falling through and // letting the model answer as if the URL had not been said; that is a // guess dressed as an answer (Vikunja #479). return phraser.Q(phraser.QueryPageOff, nil), true } ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() page, err := h.crawler.Page(ctxFetch, link) if err != nil { if errors.Is(err, crawl.ErrRobots) { return phraser.Q(phraser.QueryPageBlocked, nil), true } log.Printf("voice: web: %v", err) return phraser.Q(phraser.QueryFailPage, nil), true } if page.Text == "" { return phraser.Q(phraser.QueryPageEmpty, nil), true } // The page is handed to the phraser the same way a note is: as context for // the question he actually asked. She answers the question, she does not // recite the page. snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes) reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet}) if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true } return reply, true } // kiwixTimeout — the whole ZIM source, rewrite included. The rewrite is one // short constrained completion and the search is a LAN request; if the pair // takes longer than this something is wrong and he is better served by the // model's own answer than by more waiting. const kiwixTimeout = 20 * time.Second // searchTimeout — the whole metasearch source. websearch.Client already holds a // per-request timeout from config; this is the outer bound on the turn, so a // hung dial cannot outlive it either. Shorter than kiwixTimeout because there // is no rewrite call in front of it: the question goes out verbatim. const searchTimeout = 12 * time.Second // querySearch — the live web, through a self-hosted SearXNG. // // Ahead of Kiwix by the owner's ruling of 2026-08-02: a search reads what is // true today, a ZIM reads what was true when it was built, and the ZIM is the // fallback for a box with no line out. Everything of his still answers first — // the personal boundary is directly above this source, so a question ABOUT him // never becomes a query. // // What leaves this process is the query string and nothing else. His notes, his // facts, the persona block and the history do not travel with it: the websearch // package cannot read the store. That is the CLAUDE.md rule made mechanical, // not a promise about how the prompt is assembled. // // It claims the turn only when the search returns something. An empty result, // an unreachable instance and a 403 from an instance without the JSON format // all fall through to Kiwix, which is the point of the ordering. func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string, bool) { if h.search == nil { // Off unless configured, same as the crawler and the ZIMs. Nothing is // said about it: he never asked for a capability he did not enable. return "", false } ctxS, cancel := context.WithTimeout(ctx, searchTimeout) defer cancel() // Verbatim. No rewriter: SearXNG ranks by meaning through real engines, and // reducing "почему небо голубое" to English keywords would throw away the // language he asked in along with the ranking that handles it. resp, err := h.search.client.Search(ctxS, t.dec.Utterance, h.search.max) if err != nil { log.Printf("voice: search %q: %v", t.dec.Utterance, err) return "", false } if resp.Empty() { return "", false } // Logged on the way through, not only on failure. Without this there is no // telling from the outside whether an answer came off the web, off a ZIM or // out of the model's weights, and those are the cases worth telling apart. log.Printf("voice: search: %q → %d answers, %d results", t.dec.Utterance, len(resp.Answers), len(resp.Results)) // Handed over the same way a note, a page or an article is: evidence for the // question he asked, not something to recite. The trim is one budget over the // joined block, so a long first snippet cannot crowd out the rest. evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes) reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence}) if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true } return reply, true } // queryKiwix — the offline encyclopedia, and the fallback behind querySearch: // everything of his has already had its turn and the live search found nothing // or could not be reached. Reading beats recalling for a 1.7B either way. // // What leaves this process is the search query and nothing else. His notes, // his facts, the persona block and the history do not travel with it — the // kiwix package cannot read the store. That holds even though the server is on // the LAN, because "local sources first" is not a licence to widen what a // lookup is allowed to see. // // It claims the turn only when the search returns something. No results is not // a failure worth announcing: it means the ZIM does not cover this, and the // model answering next is the better outcome than "ничего не нашла". func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, bool) { if h.kiwix == nil { // Off unless configured, same as the crawler and the weather. Nothing // is said about it: he never asked for a capability he did not enable. return "", false } ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout) defer cancel() // The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so // a Russian sentence matches nothing at all. The rewriter turns it into a // handful of English keywords with the resident model. pattern := t.dec.Utterance if h.kiwix.rewriter != nil { q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance) if err != nil { // Fall through to the verbatim question rather than give up. It // will usually miss, and missing is a fall-through too. log.Printf("voice: kiwix: rewrite: %v", err) } else if q != "" { pattern = q } } hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max) if err != nil { log.Printf("voice: kiwix: search %q: %v", pattern, err) return "", false } if len(hits) == 0 { return "", false } top := hits[0] // Logged on the way through, not only on failure. Without this there is no // way to tell from the outside whether an answer came off a ZIM or out of // the model's weights, and those are the two cases worth telling apart. log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title) // The top hit only, read as an article rather than as a snippet. Kiwix // builds its snippet from wherever the keyword matched, which on Wikipedia // is usually the navigation box at the foot of the page — the first version // of this joined three of those and she recited "Ecological economics // Ecological footprint …" at him. The head of the article is the lead // paragraph, which is the definition the snippet was meant to be. page, aerr := h.kiwix.client.Article(ctxK, top.Path, h.kiwix.runes) if aerr != nil || page.Text == "" { if aerr != nil { log.Printf("voice: kiwix: article %s: %v", top.Path, aerr) } // The search did find something, so fall back to its snippet rather // than throw the hit away. if top.Snippet == "" { return "", false } page = crawl.Page{Title: top.Title, Text: top.Snippet} } // Handed over the same way a note or a page is: context for the question he // asked, not something to recite. snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes) reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet}) if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true } return reply, true } // queryPersonal — stop the walk on a question about him that his own data did // not answer. // // Every source above this one reads something of his: his facts, his calendar, // his tasks, his house, his notes. Everything below reads the world: an offline // Wikipedia, a page he named, the model's own weights. The world does not know // when his meeting is, and asked anyway it will produce something. // // It did. "во сколько у меня встреча" reached Kiwix on the deployed daemon, // 01-08-2026; Wikipedia matched an article on the 2015 CPISRA World Games, and // the phraser rendered it as "встреча у тебя в 2015 CPISRA World Games, где // были соревнования по плаванию". Fluent, confident, and about a swimming // competition in Nottingham. Saying "не знаю" is not a worse answer than that // one — it is the only true one. // // Note this is also the privacy edge. The rule in CLAUDE.md is that only the // utterance may leave the box, never his notes; a question that is ABOUT him // carries his life in the utterance itself, so it is the one class that should // not be sent to an upstream engine at all. The guard closes both holes with // the same test. func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (string, bool) { if !h.isPersonalTurn(ctx, t) { return "", false } log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance) return phraser.Q(phraser.QueryPersonalNone, nil), true } // personalMarkers — first-person POSSESSION, not first person generally. // // "у меня" and "мой" attach to a thing that is his, which is what makes the // question unanswerable from outside. A bare "мне" or "я" does not: "как мне // сварить борщ" and "что я могу посмотреть" are ordinary questions about the // world that happen to mention the asker, and refusing those would be the // opposite mistake. The narrow test is the point. // Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the // Russian patterns spell the boundary out as "not a letter or a digit". The // English ones keep \b, where it works. var personalMarkers = []*regexp.Regexp{ regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])у\s+меня([^\p{L}\p{N}]|$)`), regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])мо(й|я|ё|е|и|его|ей|их|им|ими|ем|ю|ею)([^\p{L}\p{N}]|$)`), regexp.MustCompile(`(?i)\bmy\b`), regexp.MustCompile(`(?i)\bdo\s+i\s+have\b`), regexp.MustCompile(`(?i)\bdid\s+i\b`), } // isPersonalQuery — the offline floor under the boundary. Possession only, and // deliberately still narrow: it answers when there is no embedder to ask, and a // broad guess made blind is worse than a narrow one. func isPersonalQuery(utterance string) bool { if utterance == "" { return false } for _, re := range personalMarkers { if re.MatchString(utterance) { return true } } return false } // isPersonalTurn — the boundary test. The seeds decide when the embedder is // there, which is every deployed box; the possession markers are the floor // underneath, for a handler with no embedder or a turn whose vector never got // computed. Same shape as the cascade: the better test leads, the offline one // always answers. func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool { h.recall.boundary.load(ctx, h.recall.embedder) if personal, world, ok := h.recall.boundary.score(t.vec); ok { if personal > world { log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world) return true } return false } return isPersonalQuery(t.dec.Utterance) } // queryGeneral — general knowledge, the last source before giving up. It always // claims: either a model answers, or Maven names the gap, or she says she does // not know. // // This is the sharpest case for the naming half. Nothing has been fetched, so // there is no passage to fall back on and no floor under the answer except the // model's weights — and a 1.7B's weights are where the invented answers come // from. With a workstation configured and asleep he is told that, rather than // told something false in a confident voice. With no workstation configured at // all the resident model answers exactly as it does today: naming a gap requires // a gap, and on that box the 1.7B is the whole product. func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) { if h.phraser == nil { // No model of any size. That is not the workstation being asleep, so it // is not that gap: it is simply not knowing. return phraser.Q(phraser.QueryUnknown, nil), true } reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) if errors.Is(err, phraser.ErrNoWorldModel) { log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance) return worldGap(), true } if err != nil || reply == "" { return phraser.Q(phraser.QueryUnknown, nil), true } return reply, true }