Skip to main content

oxios_kernel/project/
conversation_buffer.rs

1//! ConversationBuffer: in-memory circular buffer of recent conversation turns.
2//!
3//! Maintains recent N turns in memory (not persisted — restarts with empty
4//! buffer). Used for topic shift detection and project context tracking.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use uuid::Uuid;
10
11/// A single conversation turn (user message + agent response).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ConversationTurn {
14    /// User message.
15    pub user: String,
16    /// Agent response (truncated to first 200 chars for efficiency).
17    pub agent: String,
18    /// Active project at the time (nil if no project).
19    pub project_id: Option<Uuid>,
20    /// Timestamp.
21    pub timestamp: DateTime<Utc>,
22}
23
24/// In-memory circular buffer of recent conversation turns.
25#[derive(Debug, Clone)]
26pub struct ConversationBuffer {
27    /// Recent turns (bounded, oldest evicted first).
28    turns: VecDeque<ConversationTurn>,
29    /// Maximum number of turns to retain.
30    max_turns: usize,
31    /// Counter for topic check frequency limiting.
32    turns_since_topic_check: usize,
33}
34
35impl Default for ConversationBuffer {
36    fn default() -> Self {
37        Self::new(50)
38    }
39}
40
41impl ConversationBuffer {
42    /// Create a new buffer with the given maximum size.
43    pub fn new(max_turns: usize) -> Self {
44        Self {
45            turns: VecDeque::with_capacity(max_turns),
46            max_turns,
47            turns_since_topic_check: 0,
48        }
49    }
50
51    /// Record a user message (before processing).
52    pub fn push_user(&mut self, message: &str) {
53        let turn = ConversationTurn {
54            user: message.to_string(),
55            agent: String::new(),
56            project_id: None,
57            timestamp: Utc::now(),
58        };
59
60        // If last turn has empty agent, it's the pending turn — replace
61        if let Some(last) = self.turns.back_mut() {
62            if last.agent.is_empty() && last.project_id.is_none() {
63                last.user = message.to_string();
64                last.timestamp = Utc::now();
65                return;
66            }
67        }
68
69        self.turns.push_back(turn);
70
71        // Evict oldest if over capacity
72        while self.turns.len() > self.max_turns {
73            self.turns.pop_front();
74        }
75    }
76
77    /// Record an agent response and project context.
78    pub fn push_agent(&mut self, response: &str, project_id: Option<Uuid>) {
79        if let Some(last) = self.turns.back_mut() {
80            last.agent = truncate_response(response, 200);
81            last.project_id = project_id;
82        }
83    }
84
85    /// Get the most recent N turns.
86    pub fn recent(&self, n: usize) -> Vec<&ConversationTurn> {
87        self.turns.iter().rev().take(n).collect()
88    }
89
90    /// Get all turns.
91    pub fn turns(&self) -> VecDeque<ConversationTurn> {
92        self.turns.clone()
93    }
94
95    /// Get the total number of turns.
96    pub fn len(&self) -> usize {
97        self.turns.len()
98    }
99
100    /// Check if the buffer is empty.
101    pub fn is_empty(&self) -> bool {
102        self.turns.is_empty()
103    }
104
105    /// Check if topic shift detection should run.
106    pub fn should_check_topic(&self, min_turns: usize) -> bool {
107        self.turns_since_topic_check >= min_turns || self.pattern_changed()
108    }
109
110    /// Record that a topic check was performed.
111    pub fn mark_topic_checked(&mut self) {
112        self.turns_since_topic_check = 0;
113    }
114
115    /// Increment the turn counter and check if topic check should run.
116    pub fn record_turn(&mut self, min_turns: usize) -> bool {
117        self.turns_since_topic_check += 1;
118        self.should_check_topic(min_turns)
119    }
120
121    /// Detect if the conversation pattern has changed.
122    pub fn pattern_changed(&self) -> bool {
123        if self.turns.len() < 4 {
124            return false;
125        }
126
127        let all_turns: Vec<_> = self.turns.iter().collect();
128        let recent = &all_turns[all_turns.len() - 2..];
129        let previous = &all_turns[all_turns.len() - 4..all_turns.len() - 2];
130
131        let avg_recent =
132            recent.iter().map(|t| word_count(&t.user)).sum::<usize>() as f64 / recent.len() as f64;
133        let avg_prev = previous.iter().map(|t| word_count(&t.user)).sum::<usize>() as f64
134            / previous.len() as f64;
135
136        let ratio = avg_recent / avg_prev.max(1.0);
137        !(0.5..=2.0).contains(&ratio)
138    }
139
140    /// Clear all turns.
141    pub fn clear(&mut self) {
142        self.turns.clear();
143        self.turns_since_topic_check = 0;
144    }
145}
146
147/// Count words in a string.
148fn word_count(s: &str) -> usize {
149    s.split_whitespace().count()
150}
151
152/// Truncate response to max_len bytes, respecting UTF-8 char boundaries.
153fn truncate_response(response: &str, max_len: usize) -> String {
154    if response.len() <= max_len {
155        response.to_string()
156    } else {
157        let end = response
158            .char_indices()
159            .take_while(|(idx, _)| *idx < max_len)
160            .last()
161            .map(|(idx, c)| idx + c.len_utf8())
162            .unwrap_or(0);
163        if end == 0 {
164            "...".to_string()
165        } else {
166            format!("{}...", &response[..end])
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_push_user_and_agent() {
177        let mut buf = ConversationBuffer::new(10);
178        assert!(buf.is_empty());
179
180        buf.push_user("Hello, how are you?");
181        assert_eq!(buf.len(), 1);
182
183        buf.push_agent("I'm doing well!", None);
184        assert_eq!(buf.turns[0].agent, "I'm doing well!");
185    }
186
187    #[test]
188    fn test_max_capacity() {
189        let mut buf = ConversationBuffer::new(3);
190
191        for i in 1..=5 {
192            buf.push_user(&format!("msg{}", i));
193            buf.push_agent("r", None);
194        }
195
196        assert_eq!(buf.len(), 3);
197        assert_eq!(buf.recent(1)[0].user, "msg5");
198    }
199
200    #[test]
201    fn test_pattern_changed() {
202        let mut buf = ConversationBuffer::new(10);
203
204        for _ in 0..3 {
205            buf.push_user("hi");
206            buf.push_agent("hi", None);
207        }
208        assert!(!buf.pattern_changed());
209
210        buf.push_user("This is a very long message with many many many words to trigger detection");
211        buf.push_agent("ok", None);
212        assert!(buf.pattern_changed());
213    }
214}