diff --git a/crates/jarvis-core/src/llm.rs b/crates/jarvis-core/src/llm/client.rs similarity index 100% rename from crates/jarvis-core/src/llm.rs rename to crates/jarvis-core/src/llm/client.rs diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs new file mode 100644 index 0000000..522fc3b --- /dev/null +++ b/crates/jarvis-core/src/llm/history.rs @@ -0,0 +1,115 @@ +use super::client::ChatMessage; + +pub struct ConversationHistory { + system: Option, + turns: Vec, + max_turns: usize, +} + +impl ConversationHistory { + pub fn new(system_prompt: impl Into, max_turns: usize) -> Self { + Self { + system: Some(ChatMessage::system(system_prompt)), + turns: Vec::new(), + max_turns: max_turns.max(1), + } + } + + pub fn without_system(max_turns: usize) -> Self { + Self { + system: None, + turns: Vec::new(), + max_turns: max_turns.max(1), + } + } + + pub fn push_user(&mut self, content: impl Into) { + self.turns.push(ChatMessage::user(content)); + self.truncate(); + } + + pub fn push_assistant(&mut self, content: impl Into) { + self.turns.push(ChatMessage::assistant(content)); + self.truncate(); + } + + pub fn snapshot(&self) -> Vec { + let mut out = Vec::with_capacity(self.turns.len() + 1); + if let Some(s) = &self.system { + out.push(s.clone()); + } + out.extend(self.turns.iter().cloned()); + out + } + + pub fn turns(&self) -> &[ChatMessage] { + &self.turns + } + + pub fn clear(&mut self) { + self.turns.clear(); + } + + fn truncate(&mut self) { + if self.turns.len() > self.max_turns { + let drop = self.turns.len() - self.max_turns; + self.turns.drain(0..drop); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evicts_oldest_turns_but_keeps_system_prompt() { + let mut h = ConversationHistory::new("you are jarvis", 4); + h.push_user("u1"); + h.push_assistant("a1"); + h.push_user("u2"); + h.push_assistant("a2"); + h.push_user("u3"); + + let snap = h.snapshot(); + assert_eq!(snap[0].role, "system"); + assert_eq!(snap[0].content, "you are jarvis"); + assert_eq!(snap.len(), 1 + 4); + + let contents: Vec<&str> = snap.iter().skip(1).map(|m| m.content.as_str()).collect(); + assert_eq!(contents, vec!["a1", "u2", "a2", "u3"]); + } + + #[test] + fn snapshot_with_no_system_returns_only_turns() { + let mut h = ConversationHistory::without_system(3); + h.push_user("hi"); + h.push_assistant("hello"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].role, "user"); + assert_eq!(snap[1].role, "assistant"); + } + + #[test] + fn cap_of_zero_is_clamped_to_one() { + let mut h = ConversationHistory::new("sys", 0); + h.push_user("a"); + h.push_user("b"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].role, "system"); + assert_eq!(snap[1].content, "b"); + } + + #[test] + fn clear_removes_turns_but_not_system() { + let mut h = ConversationHistory::new("sys", 4); + h.push_user("u"); + h.push_assistant("a"); + h.clear(); + let snap = h.snapshot(); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].role, "system"); + } +} diff --git a/crates/jarvis-core/src/llm/mod.rs b/crates/jarvis-core/src/llm/mod.rs new file mode 100644 index 0000000..e4ba4ee --- /dev/null +++ b/crates/jarvis-core/src/llm/mod.rs @@ -0,0 +1,8 @@ +mod client; +mod history; + +pub use client::{ + ChatMessage, ConfigError, LlmClient, LlmError, + DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN, +}; +pub use history::ConversationHistory;