package main import "strings" // bufferedMsg is one prior room message the bot retained for context. type bufferedMsg struct { sender string body string isBot bool } // buildContext assembles the xAI message list under the owner's minimisation // rule ("trigger + bot replies only", §6/F8): // // - GROUP rooms: send ONLY the bot's own prior replies (assistant turns) plus // the single triggering message (user turn). Other participants' messages and // display names never reach xAI — the third-party-consent mitigation. // - 1:1 rooms: there are no third parties, so the peer's recent turns are // included too for coherence. Still no display names (pseudo "user"). // // `history` is the recent room window EXCLUDING the trigger; `triggerBody` is the // message that addressed the bot. Bodies are stripped of reply-fallback quotes so // quoted third-party text doesn't leak. func buildContext(system string, history []bufferedMsg, isDM bool, triggerBody string, maxEvents, maxTokens int) []xaiMessage { msgs := []xaiMessage{{Role: "system", Content: system}} // Keep at most the last maxEvents history items. if len(history) > maxEvents { history = history[len(history)-maxEvents:] } for _, h := range history { body := stripReplyFallback(h.body) if body == "" { continue } if h.isBot { msgs = append(msgs, xaiMessage{Role: "assistant", Content: body}) continue } if isDM { msgs = append(msgs, xaiMessage{Role: "user", Content: body}) } // group + non-bot history → dropped (privacy minimisation) } msgs = append(msgs, xaiMessage{Role: "user", Content: stripReplyFallback(triggerBody)}) return truncateToTokens(msgs, maxTokens) } // estimateTokens is a cheap upper-ish heuristic (~4 chars/token + per-message // overhead). Used only to bound request size, not for billing (billing reads the // API's returned usage). func estimateTokens(s string) int { return len([]rune(s))/4 + 4 } // truncateToTokens drops the oldest non-system, non-final messages until the // estimate fits maxTokens. The system prompt (index 0) and the final user // trigger are always preserved. func truncateToTokens(msgs []xaiMessage, maxTokens int) []xaiMessage { total := 0 for _, m := range msgs { total += estimateTokens(m.Content) } // Drop from index 1 upward (after system), never the last (trigger). for total > maxTokens && len(msgs) > 2 { total -= estimateTokens(msgs[1].Content) msgs = append(msgs[:1], msgs[2:]...) } return msgs } // stripReplyFallback removes the Matrix rich-reply fallback: leading lines that // start with "> " (the quoted parent) followed by a blank separator line. This // keeps quoted third-party text out of xAI and de-noises the prompt. func stripReplyFallback(body string) string { if !strings.HasPrefix(body, "> ") { return strings.TrimSpace(body) } lines := strings.Split(body, "\n") i := 0 for i < len(lines) && strings.HasPrefix(lines[i], ">") { i++ } for i < len(lines) && strings.TrimSpace(lines[i]) == "" { i++ } return strings.TrimSpace(strings.Join(lines[i:], "\n")) }