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            && last.agent.is_empty()
63            && last.project_id.is_none()
64        {
65            last.user = message.to_string();
66            last.timestamp = Utc::now();
67            return;
68        }
69
70        self.turns.push_back(turn);
71
72        // Evict oldest if over capacity
73        while self.turns.len() > self.max_turns {
74            self.turns.pop_front();
75        }
76    }
77
78    /// Record an agent response and project context.
79    pub fn push_agent(&mut self, response: &str, project_id: Option<Uuid>) {
80        if let Some(last) = self.turns.back_mut() {
81            last.agent = truncate_response(response, 200);
82            last.project_id = project_id;
83        }
84    }
85
86    /// Get the most recent N turns.
87    pub fn recent(&self, n: usize) -> Vec<&ConversationTurn> {
88        self.turns.iter().rev().take(n).collect()
89    }
90
91    /// Get all turns.
92    pub fn turns(&self) -> VecDeque<ConversationTurn> {
93        self.turns.clone()
94    }
95
96    /// Get the total number of turns.
97    pub fn len(&self) -> usize {
98        self.turns.len()
99    }
100
101    /// Check if the buffer is empty.
102    pub fn is_empty(&self) -> bool {
103        self.turns.is_empty()
104    }
105
106    /// Check if topic shift detection should run.
107    pub fn should_check_topic(&self, min_turns: usize) -> bool {
108        self.turns_since_topic_check >= min_turns || self.pattern_changed()
109    }
110
111    /// Record that a topic check was performed.
112    pub fn mark_topic_checked(&mut self) {
113        self.turns_since_topic_check = 0;
114    }
115
116    /// Increment the turn counter and check if topic check should run.
117    pub fn record_turn(&mut self, min_turns: usize) -> bool {
118        self.turns_since_topic_check += 1;
119        self.should_check_topic(min_turns)
120    }
121
122    /// Detect if the conversation pattern has changed.
123    pub fn pattern_changed(&self) -> bool {
124        if self.turns.len() < 4 {
125            return false;
126        }
127
128        let all_turns: Vec<_> = self.turns.iter().collect();
129        let recent = &all_turns[all_turns.len() - 2..];
130        let previous = &all_turns[all_turns.len() - 4..all_turns.len() - 2];
131
132        let avg_recent =
133            recent.iter().map(|t| word_count(&t.user)).sum::<usize>() as f64 / recent.len() as f64;
134        let avg_prev = previous.iter().map(|t| word_count(&t.user)).sum::<usize>() as f64
135            / previous.len() as f64;
136
137        let ratio = avg_recent / avg_prev.max(1.0);
138        !(0.5..=2.0).contains(&ratio)
139    }
140
141    /// Clear all turns.
142    pub fn clear(&mut self) {
143        self.turns.clear();
144        self.turns_since_topic_check = 0;
145    }
146}
147
148/// Count words in a string.
149fn word_count(s: &str) -> usize {
150    s.split_whitespace().count()
151}
152
153/// Truncate response to max_len bytes, respecting UTF-8 char boundaries.
154fn truncate_response(response: &str, max_len: usize) -> String {
155    if response.len() <= max_len {
156        response.to_string()
157    } else {
158        let end = response
159            .char_indices()
160            .take_while(|(idx, _)| *idx < max_len)
161            .last()
162            .map(|(idx, c)| idx + c.len_utf8())
163            .unwrap_or(0);
164        if end == 0 {
165            "...".to_string()
166        } else {
167            format!("{}...", &response[..end])
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_push_user_and_agent() {
178        let mut buf = ConversationBuffer::new(10);
179        assert!(buf.is_empty());
180
181        buf.push_user("Hello, how are you?");
182        assert_eq!(buf.len(), 1);
183
184        buf.push_agent("I'm doing well!", None);
185        assert_eq!(buf.turns[0].agent, "I'm doing well!");
186    }
187
188    #[test]
189    fn test_max_capacity() {
190        let mut buf = ConversationBuffer::new(3);
191
192        for i in 1..=5 {
193            buf.push_user(&format!("msg{}", i));
194            buf.push_agent("r", None);
195        }
196
197        assert_eq!(buf.len(), 3);
198        assert_eq!(buf.recent(1)[0].user, "msg5");
199    }
200
201    #[test]
202    fn test_pattern_changed() {
203        let mut buf = ConversationBuffer::new(10);
204
205        for _ in 0..3 {
206            buf.push_user("hi");
207            buf.push_agent("hi", None);
208        }
209        assert!(!buf.pattern_changed());
210
211        buf.push_user("This is a very long message with many many many words to trigger detection");
212        buf.push_agent("ok", None);
213        assert!(buf.pattern_changed());
214    }
215}