diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index b564d15f..ee931bc4 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -16,7 +16,7 @@
{
"label": "Deploy widgets",
"type": "shell",
- "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; (cd apps/widget-vojo-ai && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/vojo-ai/) & PID4=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; wait $PID4 || FAIL=1; exit $FAIL",
+ "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
"group": "none",
"presentation": {
"reveal": "always",
diff --git a/apps/ai-bot/bot.go b/apps/ai-bot/bot.go
index 220e5879..60393076 100644
--- a/apps/ai-bot/bot.go
+++ b/apps/ai-bot/bot.go
@@ -53,12 +53,26 @@ type Bot struct {
// off-lock, so several goroutines touch this shared state at once. mu is held only
// for short map operations and is NEVER held across a network or xAI call — that
// head-of-line hold was the root cause of the multi-minute silence.
- mu sync.Mutex
- seen *lruSet // event ids already handled (dedup within a session; self-locking)
- botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking)
- meta map[string]*roomMeta
- buf map[string][]bufferedMsg
- inflight map[string]bool // roomID currently generating a reply (per-room single-flight)
+ mu sync.Mutex
+ seen *lruSet // event ids already handled (dedup within a session; self-locking)
+ botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking)
+ meta map[string]*roomMeta
+ // buf and inflight are keyed by roomID THEN by thread root ("" = main timeline), so
+ // each conversation (a thread) keeps an isolated context window and an independent
+ // single-flight claim. Two messages in different threads of one room no longer block
+ // each other or pollute each other's history. roomMeta stays room-level (membership
+ // /encryption are room facts). forgetRoom drops a room's whole subtree in one delete.
+ buf map[string]map[string]*convBuf
+ inflight map[string]map[string]bool
+ // bufSeq is a monotonic counter stamped onto a convBuf on every append (guarded by mu);
+ // it orders conversations by last activity so appendBuf can LRU-evict the coldest one
+ // when a room exceeds maxConvBuffersPerRoom.
+ bufSeq uint64
+ // typingRefs counts in-flight generations per ROOM that are currently showing the
+ // "typing…" indicator. Matrix typing notifications are room-scoped (no thread_id in
+ // the CS-API), so the indicator can't be split per thread: it goes on when the first
+ // generation in a room starts and off only when the last one finishes (refcount).
+ typingRefs map[string]int
}
func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) {
@@ -80,8 +94,9 @@ func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error)
seen: newLRUSet(5000),
botSent: newLRUSet(5000),
meta: make(map[string]*roomMeta),
- buf: make(map[string][]bufferedMsg),
- inflight: make(map[string]bool),
+ buf: make(map[string]map[string]*convBuf),
+ inflight: make(map[string]map[string]bool),
+ typingRefs: make(map[string]int),
}
// Build the cascade backends only for enabled layers (config already fail-fast
@@ -330,19 +345,28 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) {
// the language-free "I'm busy" signal. The claim is taken here, synchronously and
// in transaction order, so the FIRST message for a room wins and later ones are
// dropped until release — never the reverse.
- if !b.tryClaim(roomID) {
- b.log.Debug("drop: room busy generating", "room", roomID, "sender", ev.Sender)
+ // Resolve which conversation this turn belongs to: an existing thread (continue it),
+ // a freshly rooted DM thread (auto-thread, DM-only), or the main timeline ("").
+ // Groups never auto-thread; see resolveThreadRoot.
+ threadRoot := b.resolveThreadRoot(isDM, ev, mc)
+
+ // Per-(room,thread) single-flight: a slow answer in one conversation no longer blocks
+ // another thread in the same room, and two messages in the SAME conversation still see
+ // exactly one winner. The claim is taken synchronously in transaction order.
+ if !b.tryClaim(roomID, threadRoot) {
+ b.log.Debug("drop: conversation busy generating", "room", roomID, "thread", threadRoot, "sender", ev.Sender)
return
}
- // Snapshot the room history (excludes this trigger) under the claim, then run the
- // slow generation in its own goroutine so this transaction's remaining events and
- // other rooms are not blocked by the xAI call. respond appends the trigger+answer
- // to the buffer itself, only on success (see sendReply), and releases the claim.
- history := b.snapshotBuf(roomID)
+ // Snapshot THIS conversation's history (excludes this trigger) under the claim, then
+ // run the slow generation in its own goroutine so this transaction's remaining events
+ // and other rooms/threads are not blocked by the xAI call. respond appends the
+ // trigger+answer to the same per-thread buffer on success (see sendReply) and releases
+ // the claim.
+ history := b.snapshotBuf(roomID, threadRoot)
b.safego("respond", func() {
- defer b.release(roomID)
- b.respond(ctx, roomID, isDM, ev, mc, history)
+ defer b.release(roomID, threadRoot)
+ b.respond(ctx, roomID, threadRoot, isDM, ev, mc, history)
})
}
@@ -350,7 +374,7 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) {
// never trip the per-user gate, while the global DAILY_USD_CEILING still applies.
const unlimitedCap = 1 << 30
-func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) {
+func (b *Bot) respond(ctx context.Context, roomID, threadRoot string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) {
started := time.Now()
// One telemetry row per request, populated as the flow decides its outcome and
// emitted once via defer — so every exit (deny, error, empty, paid silence, success)
@@ -438,7 +462,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
defer cancel()
msgs := buildContext(b.cfg.SystemPrompt, history, isDM, mc.Body, b.cfg.MaxCtxEvent, maxPromptTokens)
- res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID))
+ res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID, threadRoot))
// Record what the routing + generation actually did, whatever the outcome.
rl.Route = res.route
@@ -503,7 +527,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
}
b.log.Info("answered", "room", roomID, "sender", ev.Sender, "dm", isDM, "route", res.route,
"usd", res.cost.Total(), "prompt_tokens", res.usage.PromptTokens, "completion_tokens", res.usage.CompletionTokens)
- if err := b.sendReply(ctx, roomID, ev, mc, text); err != nil {
+ if err := b.sendReply(ctx, roomID, threadRoot, ev, mc, text); err != nil {
// Paid silence (§8.1): the spend is real (USD is kept — refunding it would
// under-count the ceiling), but the reply never landed. Refund the request SLOT
// so the user can retry, and react ⚠️ so the failure isn't silent.
@@ -535,15 +559,21 @@ func (b *Bot) estimateUSD(model string) float64 {
// convID returns the prompt-cache routing hint sent as x-grok-conv-id, or "" when
// GROK_PROMPT_CACHE is off. Grok caches prompt prefixes automatically; the header
-// only pins a conversation to the same backend to raise the hit rate (docs.x.ai), so
-// a stable per-room id is the right unit — every turn in a room shares the system
-// prompt and history prefix. It carries no PII (the room id is opaque) and is hashed
-// to keep it compact and non-identifying.
-func (b *Bot) convID(roomID string) string {
+// only pins a conversation to the same backend to raise the hit rate (docs.x.ai). The
+// right unit is the (room,thread) pair, not the room: once conversations are threaded,
+// each thread has its OWN system+history prefix, so pinning all of a room's threads to
+// one id would thrash the cache between divergent prefixes. The main timeline keys on
+// roomID alone (threadRoot ""), preserving the previous value for the flat case. Carries
+// no PII (ids are opaque) and is hashed to stay compact and non-identifying.
+func (b *Bot) convID(roomID, threadRoot string) string {
if !b.cfg.GrokPromptCache {
return ""
}
- return fmt.Sprintf("vojo-%08x", hashString(roomID))
+ key := roomID
+ if threadRoot != "" {
+ key = roomID + "|" + threadRoot
+ }
+ return fmt.Sprintf("vojo-%08x", hashString(key))
}
// computeUSD prices a call from the API-returned token usage (authoritative
@@ -606,23 +636,24 @@ func (b *Bot) reactEncryptedOnce(ctx context.Context, roomID, eventID string) bo
// conversation buffer so the next turn has context. It RETURNS the send error so the
// caller can handle paid silence (§8.1): a billed answer that failed to deliver must
// refund the slot and react, not vanish.
-func (b *Bot) sendReply(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error {
- if err := b.sendMessage(ctx, roomID, trigger, triggerMC, body); err != nil {
+func (b *Bot) sendReply(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
+ if err := b.sendMessage(ctx, roomID, threadRoot, trigger, triggerMC, body); err != nil {
return err
}
// Record the user trigger AND the assistant answer together, only AFTER the answer
// was sent, so a failed or empty generation never leaves a dangling user turn (a
- // question with no reply) in the buffer — which would skew later completions.
- // Single-flight guarantees no other turn for this room interleaves between the two.
- b.appendBuf(roomID, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false})
- b.appendBuf(roomID, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true})
+ // question with no reply) in the buffer — which would skew later completions. Both go
+ // to THIS conversation's buffer (roomID,threadRoot). Per-(room,thread) single-flight
+ // guarantees no other turn for this conversation interleaves between the two.
+ b.appendBuf(roomID, threadRoot, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false})
+ b.appendBuf(roomID, threadRoot, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true})
return nil
}
// sendMessage builds and sends an m.notice reply and tracks our own event id. Returns
// the send error (nil on success) so the caller can detect a failed delivery.
-func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error {
- content := buildNoticeContent(trigger.EventID, trigger.Sender, triggerMC.RelatesTo, body)
+func (b *Bot) sendMessage(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
+ content := buildNoticeContent(trigger.EventID, trigger.Sender, threadRoot, body)
id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content)
if err != nil {
b.log.Error("send failed", "room", roomID, "err", err)
@@ -633,11 +664,16 @@ func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, tr
return nil
}
-// startTypingKeepalive starts the typing indicator and keeps it alive for the whole
-// generation (the CS-API server-side typing notification expires after the 30s we
-// pass, so we refresh every 20s). The returned stop clears the indicator and is safe
-// to call once via defer. Typing is best-effort UX — failures are non-fatal.
+// startTypingKeepalive shows the room-level "typing…" indicator for the whole generation
+// and keeps it alive (the CS-API notification expires after the 30s we pass, so we refresh
+// every 20s). The returned stop is safe to call once via defer. Typing is ROOM-scoped in
+// Matrix — there is no per-thread typing — so with per-(room,thread) concurrency several
+// generations can run for one room at once. A per-room refcount keeps the indicator on
+// until the LAST of them finishes, rather than letting whichever finishes first clear it
+// out from under the others. Best-effort UX — failures are non-fatal.
func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
+ b.typingAcquire(roomID)
+
b.setTyping(ctx, roomID, true)
done := make(chan struct{})
go func() {
@@ -658,11 +694,40 @@ func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
return func() {
once.Do(func() {
close(done)
- b.setTyping(ctx, roomID, false)
+ // Only the last in-flight generation for the room clears the indicator; the
+ // others merely stop their own keepalive loop (a forgetRoom mid-flight can drop
+ // the counter to <=0, which the guard treats as "last" — still correct).
+ if b.typingRelease(roomID) {
+ b.setTyping(ctx, roomID, false)
+ }
})
}
}
+// typingAcquire registers one in-flight generation against the room-level typing indicator.
+// The increment is the only state change; the caller (re)asserts the indicator on regardless,
+// since re-sending typing=true is idempotent and refreshes the server-side 30s timeout.
+func (b *Bot) typingAcquire(roomID string) {
+ b.mu.Lock()
+ b.typingRefs[roomID]++
+ b.mu.Unlock()
+}
+
+// typingRelease drops one in-flight generation and reports whether it was the LAST one for
+// the room (refcount fell to <=0), so the caller clears the indicator only then. A forgetRoom
+// that already deleted the key leaves a missing entry: the decrement reads 0 and lands at -1
+// (<=0), so the guard still treats it as "last" and the negative entry is deleted — no leak.
+func (b *Bot) typingRelease(roomID string) (last bool) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.typingRefs[roomID]--
+ last = b.typingRefs[roomID] <= 0
+ if last {
+ delete(b.typingRefs, roomID)
+ }
+ return last
+}
+
// setTyping sets/clears the bot's typing indicator (best-effort UX; failures are
// non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive.
func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
@@ -672,13 +737,17 @@ func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
}
// buildNoticeContent builds the reply. m.notice (not m.text) so the anti-loop
-// skip catches our own output. Thread-aware (F27): a trigger from a thread gets a
-// thread relation so the answer lands in the thread, not the main timeline.
-func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body string) map[string]any {
+// skip catches our own output. threadRoot decides where the answer lands: when non-empty
+// the answer carries an m.thread relation rooted there (F27) — either replying inside an
+// existing thread or auto-rooting a NEW DM conversation on the trigger. The caller's
+// resolveThreadRoot makes that choice and is DM-gated, so a group answer never gets a
+// thread relation it didn't already have. When empty the answer is a plain top-level
+// reply (groups, and DMs with conversations off).
+func buildNoticeContent(replyTo, sender, threadRoot, body string) map[string]any {
relates := map[string]any{}
- if triggerRelates != nil && triggerRelates.RelType == "m.thread" && triggerRelates.EventID != "" {
+ if threadRoot != "" {
relates["rel_type"] = "m.thread"
- relates["event_id"] = triggerRelates.EventID
+ relates["event_id"] = threadRoot
relates["is_falling_back"] = true
relates["m.in_reply_to"] = map[string]any{"event_id": replyTo}
} else {
@@ -701,24 +770,55 @@ func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body
return content
}
-// --- per-room single-flight ----------------------------------------------------
+// --- per-(room,thread) single-flight ---------------------------------------------
-// tryClaim marks a room as generating and returns true if the caller won the claim
-// (no generation was already in flight). The loser must drop its message.
-func (b *Bot) tryClaim(roomID string) bool {
+// resolveThreadRoot decides which conversation a trigger belongs to, returning the thread
+// root event id, or "" for the main timeline. Order: (1) a trigger already inside a thread
+// continues that thread; (2) in a 1:1 DM, a top-level message roots a NEW conversation on
+// itself (ChatGPT-style "new chat"); (3) otherwise — EVERY group message — the main timeline
+// (""). This is the single place auto-threading is decided and it is hard-gated on isDM, so
+// a group is NEVER auto-threaded (the group gate is structural, not a flag). buildNoticeContent
+// emits the matching m.thread relation for the same value, so the conversation we serialize on
+// is always the one the answer lands in.
+func (b *Bot) resolveThreadRoot(isDM bool, ev *Event, mc *MessageContent) string {
+ if mc.RelatesTo != nil && mc.RelatesTo.RelType == "m.thread" && mc.RelatesTo.EventID != "" {
+ return mc.RelatesTo.EventID
+ }
+ if isDM {
+ return ev.EventID
+ }
+ return ""
+}
+
+// tryClaim marks a (room,thread) conversation as generating and returns true if the caller
+// won the claim (nothing was already in flight for that exact conversation). The loser
+// drops its message. Different threads of one room claim independently — a slow answer in
+// one conversation never blocks another. The check-and-set is atomic under b.mu (one map,
+// no per-thread mutex), so there is no lazy-lock TOCTOU.
+func (b *Bot) tryClaim(roomID, threadRoot string) bool {
b.mu.Lock()
defer b.mu.Unlock()
- if b.inflight[roomID] {
+ m := b.inflight[roomID]
+ if m == nil {
+ m = make(map[string]bool)
+ b.inflight[roomID] = m
+ }
+ if m[threadRoot] {
return false
}
- b.inflight[roomID] = true
+ m[threadRoot] = true
return true
}
-func (b *Bot) release(roomID string) {
+func (b *Bot) release(roomID, threadRoot string) {
b.mu.Lock()
defer b.mu.Unlock()
- delete(b.inflight, roomID)
+ if m := b.inflight[roomID]; m != nil {
+ delete(m, threadRoot)
+ if len(m) == 0 {
+ delete(b.inflight, roomID)
+ }
+ }
}
// --- per-room metadata helpers (all guarded by b.mu; probes run outside it) -----
@@ -752,8 +852,9 @@ func (b *Bot) forgetRoom(roomID string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.meta, roomID)
- delete(b.buf, roomID)
- delete(b.inflight, roomID)
+ delete(b.buf, roomID) // drops the room's whole per-thread subtree in one delete
+ delete(b.inflight, roomID) // (nested maps keyed by roomID keep forgetRoom O(1))
+ delete(b.typingRefs, roomID)
}
// ensureEncryption returns whether the room is encrypted, probing the CS-API once
@@ -824,28 +925,69 @@ func (b *Bot) ensureCounts(ctx context.Context, roomID string) (countsKnown, isD
return false, false, false
}
-func (b *Bot) snapshotBuf(roomID string) []bufferedMsg {
+// convBuf is one conversation's (one thread's) rolling context window plus a last-touch
+// stamp used for LRU eviction. Without eviction, a long-lived control DM that spawns many
+// ChatGPT-style conversations would accumulate one buffer per conversation for the whole
+// process lifetime (forgetRoom only frees them on room leave/ban).
+type convBuf struct {
+ msgs []bufferedMsg
+ touched uint64 // b.bufSeq at last append; higher = more recent
+}
+
+// maxConvBuffersPerRoom bounds how many conversation buffers a single room retains. A
+// human DM rarely keeps this many live conversations at once; an evicted cold conversation
+// just rebuilds its window from scratch on its next turn (same as after a restart), so the
+// only cost of eviction is a one-turn cold start, never a wrong answer.
+const maxConvBuffersPerRoom = 64
+
+func (b *Bot) snapshotBuf(roomID, threadRoot string) []bufferedMsg {
b.mu.Lock()
defer b.mu.Unlock()
- src := b.buf[roomID]
- if len(src) == 0 {
+ // A never-seen conversation has no buffer (nil inner map / nil convBuf) → nil history,
+ // exactly what a fresh "new chat" wants (context = system + trigger).
+ cb := b.buf[roomID][threadRoot]
+ if cb == nil || len(cb.msgs) == 0 {
return nil
}
- out := make([]bufferedMsg, len(src))
- copy(out, src)
+ out := make([]bufferedMsg, len(cb.msgs))
+ copy(out, cb.msgs)
return out
}
-func (b *Bot) appendBuf(roomID string, msg bufferedMsg) {
+func (b *Bot) appendBuf(roomID, threadRoot string, msg bufferedMsg) {
b.mu.Lock()
defer b.mu.Unlock()
limit := b.cfg.MaxCtxEvent * 2
if limit < 8 {
limit = 8
}
- buf := append(b.buf[roomID], msg)
- if len(buf) > limit {
- buf = buf[len(buf)-limit:]
+ m := b.buf[roomID]
+ if m == nil {
+ m = make(map[string]*convBuf)
+ b.buf[roomID] = m
+ }
+ cb := m[threadRoot]
+ if cb == nil {
+ cb = &convBuf{}
+ m[threadRoot] = cb
+ }
+ cb.msgs = append(cb.msgs, msg)
+ if len(cb.msgs) > limit {
+ cb.msgs = cb.msgs[len(cb.msgs)-limit:]
+ }
+ b.bufSeq++
+ cb.touched = b.bufSeq
+
+ // LRU-evict the least-recently-touched conversation once the room exceeds the cap. The
+ // just-touched conversation has the highest `touched`, so it is never the victim.
+ if len(m) > maxConvBuffersPerRoom {
+ var victim string
+ var oldest uint64
+ for k, v := range m {
+ if victim == "" || v.touched < oldest {
+ victim, oldest = k, v.touched
+ }
+ }
+ delete(m, victim)
}
- b.buf[roomID] = buf
}
diff --git a/apps/ai-bot/buffers_test.go b/apps/ai-bot/buffers_test.go
new file mode 100644
index 00000000..09e651fd
--- /dev/null
+++ b/apps/ai-bot/buffers_test.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+ "fmt"
+ "testing"
+)
+
+// TestConvIDFlatCaseInvariant pins the load-bearing prompt-cache invariant of the
+// per-(room,thread) refactor: the MAIN-timeline conv id (threadRoot "") must stay
+// byte-identical to the pre-threading per-room value, so rooms that existed before
+// threading keep their warm Grok prompt-cache routing; and a thread must get a DISTINCT
+// id so divergent thread prefixes don't thrash one shared cache slot. Also asserts the
+// flag-off path returns "" (no header) for both cases.
+func TestConvIDFlatCaseInvariant(t *testing.T) {
+ b := &Bot{cfg: &Config{GrokPromptCache: true}}
+
+ flat := b.convID("!room:vojo.chat", "")
+ legacy := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat"))
+ if flat != legacy {
+ t.Fatalf("flat convID = %q, want legacy per-room value %q (prompt-cache continuity)", flat, legacy)
+ }
+
+ threaded := b.convID("!room:vojo.chat", "$root")
+ if threaded == flat {
+ t.Fatalf("threaded convID must differ from the flat/main-timeline id, both = %q", flat)
+ }
+ if want := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat|$root")); threaded != want {
+ t.Fatalf("threaded convID = %q, want %q", threaded, want)
+ }
+
+ off := &Bot{cfg: &Config{GrokPromptCache: false}}
+ if got := off.convID("!room:vojo.chat", ""); got != "" {
+ t.Fatalf("convID with GrokPromptCache off must be empty, got %q", got)
+ }
+ if got := off.convID("!room:vojo.chat", "$root"); got != "" {
+ t.Fatalf("threaded convID with GrokPromptCache off must be empty, got %q", got)
+ }
+}
+
+// TestSnapshotAppendBufNilPaths exercises the nested-map nil reads the per-(room,thread)
+// keying introduced: a never-seen room, a known room but unknown thread, and the round-trip
+// of a single append. A nil inner map / nil convBuf must read back as nil history (exactly
+// what a fresh "new chat" wants), never panic.
+func TestSnapshotAppendBufNilPaths(t *testing.T) {
+ b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
+
+ if got := b.snapshotBuf("!a", ""); got != nil {
+ t.Fatalf("snapshot of a never-seen room must be nil, got %v", got)
+ }
+
+ b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: "hi", isBot: false})
+ got := b.snapshotBuf("!a", "")
+ if len(got) != 1 || got[0].body != "hi" {
+ t.Fatalf("snapshot after one append = %v, want one message 'hi'", got)
+ }
+
+ // Same room, a DIFFERENT thread that was never appended to → nil (inner map exists, key absent).
+ if got := b.snapshotBuf("!a", "$other"); got != nil {
+ t.Fatalf("snapshot of an unknown thread in a known room must be nil, got %v", got)
+ }
+ // A different room entirely → nil (outer key absent).
+ if got := b.snapshotBuf("!b", ""); got != nil {
+ t.Fatalf("snapshot of a different room must be nil, got %v", got)
+ }
+}
+
+// TestAppendBufTrimsToLimit asserts a single conversation's buffer is bounded to
+// MaxCtxEvent*2 (min 8) and keeps the most recent messages (FIFO drop of the oldest).
+func TestAppendBufTrimsToLimit(t *testing.T) {
+ b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
+ const limit = 20 // MaxCtxEvent*2
+ for i := 0; i < limit+5; i++ {
+ b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: fmt.Sprintf("m%d", i)})
+ }
+ got := b.snapshotBuf("!a", "")
+ if len(got) != limit {
+ t.Fatalf("buffer length = %d, want capped at %d", len(got), limit)
+ }
+ // Oldest 5 dropped; the window starts at m5 and ends at m24.
+ if got[0].body != "m5" || got[len(got)-1].body != "m24" {
+ t.Fatalf("buffer window = [%s..%s], want [m5..m24]", got[0].body, got[len(got)-1].body)
+ }
+}
+
+// TestAppendBufLRUEviction proves the per-room conversation-buffer cap: once a room exceeds
+// maxConvBuffersPerRoom, the LEAST-recently-touched conversation is evicted, and the
+// just-touched conversation is never the victim.
+func TestAppendBufLRUEviction(t *testing.T) {
+ b := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
+
+ // Touch exactly maxConvBuffersPerRoom+1 distinct threads, oldest-first.
+ for i := 0; i <= maxConvBuffersPerRoom; i++ {
+ b.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
+ }
+ if n := len(b.buf["!a"]); n != maxConvBuffersPerRoom {
+ t.Fatalf("room buffer count = %d, want capped at %d", n, maxConvBuffersPerRoom)
+ }
+ if got := b.snapshotBuf("!a", "$t0"); got != nil {
+ t.Fatalf("the coldest conversation ($t0) must have been evicted, got %v", got)
+ }
+ if got := b.snapshotBuf("!a", fmt.Sprintf("$t%d", maxConvBuffersPerRoom)); got == nil {
+ t.Fatal("the just-appended conversation must never be the eviction victim")
+ }
+
+ // Re-touch protection: fill to the cap, re-touch the OLDEST, then overflow by one.
+ // The re-touched conversation must survive; the new second-oldest becomes the victim.
+ b2 := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
+ for i := 0; i < maxConvBuffersPerRoom; i++ {
+ b2.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
+ }
+ b2.appendBuf("!a", "$t0", bufferedMsg{body: "x"}) // re-touch the oldest → now newest
+ b2.appendBuf("!a", "$overflow", bufferedMsg{body: "x"}) // overflow → evict the coldest
+ if got := b2.snapshotBuf("!a", "$t0"); got == nil {
+ t.Fatal("a re-touched conversation ($t0) must NOT be evicted")
+ }
+ if got := b2.snapshotBuf("!a", "$t1"); got != nil {
+ t.Fatalf("the new coldest conversation ($t1) must be the victim, got %v", got)
+ }
+}
+
+// TestTypingRefcount covers the per-room typing refcount the per-(room,thread) concurrency
+// relies on (Matrix typing is room-scoped, so several thread generations share one
+// indicator). Only the LAST release reports "last" (clears the indicator), and a release
+// after forgetRoom dropped the key must self-heal without leaving a leaked negative entry.
+func TestTypingRefcount(t *testing.T) {
+ b := &Bot{typingRefs: make(map[string]int)}
+
+ b.typingAcquire("!a")
+ b.typingAcquire("!a")
+ if b.typingRefs["!a"] != 2 {
+ t.Fatalf("typingRefs after two acquires = %d, want 2", b.typingRefs["!a"])
+ }
+ if last := b.typingRelease("!a"); last {
+ t.Fatal("first release of two must NOT be the last")
+ }
+ if last := b.typingRelease("!a"); !last {
+ t.Fatal("second release of two must be the last")
+ }
+ if _, ok := b.typingRefs["!a"]; ok {
+ t.Fatalf("the key must be deleted once the refcount hits 0, still present = %d", b.typingRefs["!a"])
+ }
+
+ // forgetRoom mid-flight: two acquires, then the room is forgotten (key deleted), then the
+ // in-flight generations release against a missing key. Each lands at -1 (<=0 → "last") and
+ // deletes the entry, so no persistent negative count leaks.
+ b.typingAcquire("!b")
+ b.typingAcquire("!b")
+ b.forgetRoom("!b")
+ if last := b.typingRelease("!b"); !last {
+ t.Fatal("a release after forgetRoom must report last (counter <= 0)")
+ }
+ if last := b.typingRelease("!b"); !last {
+ t.Fatal("a second stale release must also report last")
+ }
+ if _, ok := b.typingRefs["!b"]; ok {
+ t.Fatalf("no negative entry must leak after forgetRoom + stale releases, value = %d", b.typingRefs["!b"])
+ }
+}
diff --git a/apps/ai-bot/concurrency_test.go b/apps/ai-bot/concurrency_test.go
index dde04dbe..46f10cc5 100644
--- a/apps/ai-bot/concurrency_test.go
+++ b/apps/ai-bot/concurrency_test.go
@@ -5,52 +5,89 @@ import (
"testing"
)
-// TestSingleFlightClaim documents the per-room single-flight invariant the async
-// refactor relies on: at most one generation per room at a time, the claim is
-// independent per room, and a release re-arms the room. handleEvent takes this claim
-// synchronously in transaction order, so the FIRST message for a room wins and later
-// ones are dropped until release (never the reverse).
+// TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the
+// async refactor relies on: at most one generation per conversation at a time, the claim
+// is independent per (room,thread), and a release re-arms only that conversation.
+// handleEvent takes this claim synchronously in transaction order, so the FIRST message
+// for a conversation wins and later ones are dropped until release (never the reverse).
func TestSingleFlightClaim(t *testing.T) {
- b := &Bot{inflight: make(map[string]bool)}
+ b := &Bot{inflight: make(map[string]map[string]bool)}
- if !b.tryClaim("!a") {
- t.Fatal("first claim on !a should win")
+ if !b.tryClaim("!a", "") {
+ t.Fatal("first claim on (!a, main) should win")
}
- if b.tryClaim("!a") {
- t.Fatal("second claim on !a must fail while in flight")
+ if b.tryClaim("!a", "") {
+ t.Fatal("second claim on (!a, main) must fail while in flight")
}
- if !b.tryClaim("!b") {
+ // A DIFFERENT thread in the SAME room must claim independently — the whole point of
+ // per-(room,thread) single-flight: a slow answer in one conversation cannot block
+ // another conversation in the same room.
+ if !b.tryClaim("!a", "$root1") {
+ t.Fatal("a different thread in the same room must claim independently")
+ }
+ if b.tryClaim("!a", "$root1") {
+ t.Fatal("second claim on (!a, $root1) must fail while in flight")
+ }
+ if !b.tryClaim("!b", "") {
t.Fatal("a different room must claim independently")
}
- b.release("!a")
- if !b.tryClaim("!a") {
- t.Fatal("after release !a must be claimable again")
+ b.release("!a", "")
+ if !b.tryClaim("!a", "") {
+ t.Fatal("after release (!a, main) must be claimable again")
+ }
+ // Releasing the main timeline must NOT free the thread's claim.
+ if b.tryClaim("!a", "$root1") {
+ t.Fatal("releasing (!a, main) must not free (!a, $root1)")
}
}
// TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same
-// room and asserts EXACTLY ONE wins the claim — the property that prevents two
-// concurrent generations (double xAI spend) for one room. Run under -race.
+// conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent
+// generations (double xAI spend) for one conversation. It also races two DIFFERENT
+// threads of one room together and asserts each has its own single winner, proving the
+// claim is independent per (room,thread), not per room. Run under -race.
func TestSingleFlightClaimExactlyOneWinner(t *testing.T) {
- b := &Bot{inflight: make(map[string]bool)}
+ b := &Bot{inflight: make(map[string]map[string]bool)}
const n = 64
- var wins int64
+ var sameWins, threadAWins, threadBWins int64
var mu sync.Mutex
var wg sync.WaitGroup
- wg.Add(n)
+ wg.Add(n * 3)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
- if b.tryClaim("!room") {
+ if b.tryClaim("!room", "$same") {
mu.Lock()
- wins++
+ sameWins++
+ mu.Unlock()
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ if b.tryClaim("!room", "$a") {
+ mu.Lock()
+ threadAWins++
+ mu.Unlock()
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ if b.tryClaim("!room", "$b") {
+ mu.Lock()
+ threadBWins++
mu.Unlock()
}
}()
}
wg.Wait()
- if wins != 1 {
- t.Fatalf("exactly one goroutine must win the claim, got %d", wins)
+ if sameWins != 1 {
+ t.Fatalf("exactly one goroutine must win (!room, $same), got %d", sameWins)
+ }
+ if threadAWins != 1 {
+ t.Fatalf("exactly one goroutine must win (!room, $a), got %d", threadAWins)
+ }
+ if threadBWins != 1 {
+ t.Fatalf("exactly one goroutine must win (!room, $b), got %d", threadBWins)
}
}
diff --git a/apps/ai-bot/markdown_test.go b/apps/ai-bot/markdown_test.go
index 6a7b4cd7..8faba2b1 100644
--- a/apps/ai-bot/markdown_test.go
+++ b/apps/ai-bot/markdown_test.go
@@ -121,7 +121,7 @@ func TestMarkdownAdversarialNoPanicNoInjection(t *testing.T) {
}
func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
- c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "Here is **bold**.")
+ c := buildNoticeContent("$evt", "@u:vojo.chat", "", "Here is **bold**.")
if c["format"] != matrixHTMLFormat {
t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat)
}
@@ -135,7 +135,7 @@ func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
}
func TestBuildNoticeContentSkipsFormattedForPlain(t *testing.T) {
- c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "no markdown here")
+ c := buildNoticeContent("$evt", "@u:vojo.chat", "", "no markdown here")
if _, ok := c["format"]; ok {
t.Fatalf("format must be absent for plain text")
}
diff --git a/apps/ai-bot/threads_test.go b/apps/ai-bot/threads_test.go
new file mode 100644
index 00000000..90cafa42
--- /dev/null
+++ b/apps/ai-bot/threads_test.go
@@ -0,0 +1,70 @@
+package main
+
+import "testing"
+
+// TestResolveThreadRoot pins the conversation-routing gate — the single place that decides
+// whether a trigger continues a thread, roots a NEW conversation, or stays on the main
+// timeline. The load-bearing invariant is the LAST case: a group is NEVER auto-threaded, so
+// the threading feature can't change group behavior. Auto-threading in 1:1 DMs is always on
+// (no flag); the only gate is isDM.
+func TestResolveThreadRoot(t *testing.T) {
+ inThread := &MessageContent{RelatesTo: &RelatesTo{RelType: "m.thread", EventID: "$root"}}
+ topLevel := &MessageContent{}
+ reply := &MessageContent{RelatesTo: &RelatesTo{RelType: "", EventID: "", InReplyTo: &InReplyTo{EventID: "$x"}}}
+ ev := &Event{EventID: "$trigger"}
+
+ cases := []struct {
+ name string
+ isDM bool
+ mc *MessageContent
+ want string
+ }{
+ {"existing thread continues (DM)", true, inThread, "$root"},
+ {"existing thread continues (group)", false, inThread, "$root"},
+ {"DM top-level roots a new thread on the trigger", true, topLevel, "$trigger"},
+ {"GROUP top-level never auto-threads", false, topLevel, ""},
+ {"DM plain reply (no m.thread) roots a new thread", true, reply, "$trigger"},
+ {"GROUP plain reply never auto-threads", false, reply, ""},
+ }
+ for _, c := range cases {
+ b := &Bot{}
+ if got := b.resolveThreadRoot(c.isDM, ev, c.mc); got != c.want {
+ t.Errorf("%s: resolveThreadRoot = %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+// TestBuildNoticeContentThreadRelation asserts the reply lands where resolveThreadRoot
+// decided: a non-empty threadRoot emits an m.thread relation (so the answer joins the
+// conversation), an empty one emits only m.in_reply_to (a plain top-level reply).
+func TestBuildNoticeContentThreadRelation(t *testing.T) {
+ threaded := buildNoticeContent("$reply", "@u:vojo.chat", "$root", "hi")
+ rel, ok := threaded["m.relates_to"].(map[string]any)
+ if !ok {
+ t.Fatalf("m.relates_to missing or wrong type: %T", threaded["m.relates_to"])
+ }
+ if rel["rel_type"] != "m.thread" {
+ t.Errorf("rel_type = %v, want m.thread", rel["rel_type"])
+ }
+ if rel["event_id"] != "$root" {
+ t.Errorf("event_id = %v, want $root", rel["event_id"])
+ }
+ if rel["is_falling_back"] != true {
+ t.Errorf("is_falling_back = %v, want true", rel["is_falling_back"])
+ }
+ if inReply, _ := rel["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" {
+ t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"])
+ }
+
+ topLevel := buildNoticeContent("$reply", "@u:vojo.chat", "", "hi")
+ rel2, ok := topLevel["m.relates_to"].(map[string]any)
+ if !ok {
+ t.Fatalf("m.relates_to missing or wrong type: %T", topLevel["m.relates_to"])
+ }
+ if _, hasThread := rel2["rel_type"]; hasThread {
+ t.Errorf("a top-level reply must not carry rel_type, got %v", rel2["rel_type"])
+ }
+ if inReply, _ := rel2["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" {
+ t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"])
+ }
+}
diff --git a/apps/widget-vojo-ai/.gitignore b/apps/widget-vojo-ai/.gitignore
deleted file mode 100644
index 205a6bfb..00000000
--- a/apps/widget-vojo-ai/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-node_modules/
-dist/
-.vite/
-*.local
diff --git a/apps/widget-vojo-ai/index.html b/apps/widget-vojo-ai/index.html
deleted file mode 100644
index 723f3c37..00000000
--- a/apps/widget-vojo-ai/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
- );
-};
-
-type AppProps = {
- bootstrap: WidgetBootstrap;
- api: WidgetApi;
-};
-
-export function App({ bootstrap, api }: AppProps) {
- const t = createT(bootstrap.clientLanguage);
- const [aboutOpen, setAboutOpen] = useState(false);
-
- // Render the action ONLY when the host advertised the capability. UI hint
- // only — the host re-checks the capability against the trusted config before
- // honouring the verb, so a forced render here cannot escalate anything.
- const canAddToChat = bootstrap.capabilities.includes(ADD_TO_CHAT_CAP);
-
- // Follow host theme changes (initial theme applied in main.tsx before paint).
- useEffect(() => {
- api.on('themeChange', (name) => {
- document.documentElement.dataset.theme = name;
- });
- }, [api]);
-
- return (
-
-
-
- {canAddToChat && (
-
- )}
-
-
-
-
- {aboutOpen && setAboutOpen(false)} />}
-
- );
-}
diff --git a/apps/widget-vojo-ai/src/bootstrap.ts b/apps/widget-vojo-ai/src/bootstrap.ts
deleted file mode 100644
index 412595b4..00000000
--- a/apps/widget-vojo-ai/src/bootstrap.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-// Parse the URL params the bot widget host appends when loading experience.url.
-// Source of truth on the host side:
-// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
-// Keep this in sync if the host adds params.
-
-export type WidgetBootstrap = {
- widgetId: string;
- parentUrl: string;
- parentOrigin: string;
- roomId: string;
- userId: string;
- botId: string;
- botMxid: string;
- /** Elevated host verbs this bot is allowed to drive, forwarded by the host
- * as a CSV render hint (NOT an authorization input — the host re-checks the
- * capability against the trusted config before acting). Used here only to
- * decide whether to draw the «Add to chat» button. Empty CSV ⇒ `[]` (F19). */
- capabilities: string[];
- theme: 'light' | 'dark';
- clientLanguage: string;
-};
-
-export type BootstrapResult =
- | { ok: true; bootstrap: WidgetBootstrap }
- | { ok: false; missing: string[] };
-
-const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
-
-export const readBootstrap = (search: string): BootstrapResult => {
- const params = new URLSearchParams(search);
- const get = (k: string) => params.get(k) ?? '';
-
- const missing = REQUIRED.filter((k) => !params.get(k));
- if (missing.length > 0) return { ok: false, missing: [...missing] };
-
- // Origin is what the widget validates against on incoming postMessage — see
- // widget-api.ts. Falling back to '*' would defeat the security boundary, so a
- // malformed parentUrl bails out as a missing-param error.
- let parentOrigin: string;
- try {
- parentOrigin = new URL(get('parentUrl')).origin;
- } catch {
- return { ok: false, missing: ['parentUrl'] };
- }
-
- // CSV → string[]. Empty string MUST become [] — `''.split(',')` yields `['']`
- // which would make `capabilities.includes(...)` subtly wrong downstream (F19).
- const capsRaw = get('capabilities');
- const capabilities = capsRaw ? capsRaw.split(',').filter(Boolean) : [];
-
- const themeRaw = get('theme');
- const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
-
- return {
- ok: true,
- bootstrap: {
- widgetId: get('widgetId'),
- parentUrl: get('parentUrl'),
- parentOrigin,
- roomId: get('roomId'),
- userId: get('userId'),
- botId: get('botId'),
- botMxid: get('botMxid'),
- capabilities,
- theme,
- clientLanguage: get('clientLanguage'),
- },
- };
-};
diff --git a/apps/widget-vojo-ai/src/i18n/en.ts b/apps/widget-vojo-ai/src/i18n/en.ts
deleted file mode 100644
index de43432f..00000000
--- a/apps/widget-vojo-ai/src/i18n/en.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-// English fallback. Mirror the RU key set; `Record` enforces
-// that every RU key has an EN counterpart at compile time.
-
-import type { StringKey } from './ru';
-
-export const EN: Record = {
- 'card.add.name': 'Add to chat',
- 'card.add.desc': 'Invite Vojo AI into a room — it will reply to mentions there.',
- 'card.privacy.name': 'Privacy & data',
- 'card.privacy.desc': 'What is sent to the AI service and how it is stored',
- 'about.title': 'Vojo AI privacy',
- 'about.body-1': 'Vojo AI is an AI-powered virtual assistant. The model is provided by xAI (USA).',
- 'about.body-2':
- 'When you mention the robot in a chat or message it directly, the text of messages from that chat is sent to xAI to generate a reply and may be stored there for up to 30 days.',
- 'about.body-3':
- 'If the robot is added to a group chat, other participants’ messages may also reach xAI. Only add the robot where appropriate, and let the other participants know.',
- 'about.body-4':
- 'Do not send the robot personal, payment, or other confidential data. Replies are AI-generated and may contain errors.',
- 'about.consent':
- 'By adding the robot to a chat, you consent to sending that chat’s messages to xAI.',
- 'about.close': 'Close',
- 'about.aria-close': 'Close “Vojo AI privacy”',
- 'bootstrap.failed': 'Widget failed to start',
- 'bootstrap.missing-params': 'Missing required URL params: {names}.',
- 'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
-};
diff --git a/apps/widget-vojo-ai/src/i18n/index.ts b/apps/widget-vojo-ai/src/i18n/index.ts
deleted file mode 100644
index 686c299a..00000000
--- a/apps/widget-vojo-ai/src/i18n/index.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match —
-// any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx
-// can also call `createT()` without args before bootstrap completes (falls back
-// to navigator.language, then RU).
-
-import { RU, type StringKey } from './ru';
-import { EN } from './en';
-
-const interpolate = (s: string, vars?: Record): string => {
- if (!vars) return s;
- return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
-};
-
-const pickDict = (clientLanguage: string | undefined): Record => {
- const lang = (
- clientLanguage ||
- (typeof navigator !== 'undefined' ? navigator.language : '') ||
- 'ru'
- ).toLowerCase();
- return lang.startsWith('en') ? EN : RU;
-};
-
-export type T = (key: StringKey, vars?: Record) => string;
-
-export const createT = (clientLanguage?: string): T => {
- const dict = pickDict(clientLanguage);
- return (key, vars) => interpolate(dict[key], vars);
-};
-
-export type { StringKey };
diff --git a/apps/widget-vojo-ai/src/i18n/ru.ts b/apps/widget-vojo-ai/src/i18n/ru.ts
deleted file mode 100644
index ef0187ee..00000000
--- a/apps/widget-vojo-ai/src/i18n/ru.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// Russian primary copy. To add a string:
-// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
-// and the `StringKey` type derive from it),
-// 2. add the same key + EN value in `en.ts`,
-// 3. consume via `t('key', { var: 'x' })`.
-// Interpolation uses `{name}` placeholders resolved against the second arg.
-//
-// The hero (name/avatar) is OWNED BY THE HOST (src/app/features/bots/BotShell).
-// The widget renders action cards + a privacy modal (Telegram «О боте» pattern).
-
-export const RU = {
- // Action card.
- 'card.add.name': 'Добавить в чат',
- 'card.add.desc': 'Пригласите Vojo AI в комнату — он будет отвечать на упоминания в ней.',
- // Privacy card → opens the full policy modal.
- 'card.privacy.name': 'Конфиденциальность и данные',
- 'card.privacy.desc': 'Что отправляется в ИИ-сервис и как хранится',
- // Full privacy notice (the «Политика» spelled out — see card.privacy).
- 'about.title': 'Конфиденциальность Vojo AI',
- 'about.body-1':
- 'Vojo AI — виртуальный собеседник на базе искусственного интеллекта. Модель предоставляет компания xAI (США).',
- 'about.body-2':
- 'Когда вы упоминаете робота в чате или пишете ему напрямую, текст сообщений из этого чата передаётся в xAI для генерации ответа и может храниться там до 30 дней.',
- 'about.body-3':
- 'Если робот добавлен в групповой чат, в xAI могут попасть и сообщения других участников. Добавляйте робота только туда, где это уместно, и предупреждайте собеседников.',
- 'about.body-4':
- 'Не отправляйте роботу персональные, платёжные или иные конфиденциальные данные. Ответы генерирует ИИ — они могут содержать ошибки.',
- 'about.consent': 'Добавляя робота в чат, вы соглашаетесь на передачу сообщений этого чата в xAI.',
- 'about.close': 'Закрыть',
- 'about.aria-close': 'Закрыть «Конфиденциальность Vojo AI»',
- 'bootstrap.failed': 'Виджет не запустился',
- 'bootstrap.missing-params': 'Не хватает параметров URL: {names}.',
- 'bootstrap.embedded-only': 'Эта страница предназначена для встраивания в Vojo по адресу {route}.',
-} as const;
-
-export type StringKey = keyof typeof RU;
diff --git a/apps/widget-vojo-ai/src/main.tsx b/apps/widget-vojo-ai/src/main.tsx
deleted file mode 100644
index 48bd45c6..00000000
--- a/apps/widget-vojo-ai/src/main.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { render } from 'preact';
-import { readBootstrap } from './bootstrap';
-import { App } from './App';
-import { createT } from './i18n';
-import { WidgetApi } from './widget-api';
-import './styles.css';
-
-// Input-mode detector for hover styling (same rationale as widget-telegram):
-// Capacitor's Android WebView synthesises a sticky `:hover` on the tapped
-// element after a tap. CSS gates `:hover` on `:root[data-input="mouse"]`; truth
-// comes from `pointerdown.pointerType`. Default 'mouse' is no worse than any
-// interaction-media query, which mis-report on shipping devices.
-const setInputMode = (mode: 'touch' | 'mouse'): void => {
- document.documentElement.dataset.input = mode;
-};
-setInputMode('mouse');
-window.addEventListener(
- 'pointerdown',
- (event) => {
- setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch');
- },
- { passive: true, capture: true }
-);
-
-const root = document.getElementById('app');
-if (!root) {
- throw new Error('#app root element missing — index.html out of sync');
-}
-
-const result = readBootstrap(window.location.search);
-
-if (!result.ok) {
- // Either the widget URL was opened directly (no host params) or a host bug
- // failed to provide them. Render a self-contained diagnostic. Bootstrap failed
- // before clientLanguage could be read, so createT falls back to
- // navigator.language.
- const t = createT();
- render(
-