From d31a733324f127a30cb249d1798193d3656d2891 Mon Sep 17 00:00:00 2001 From: terra tauri Date: Sun, 1 Feb 2026 23:11:39 -0800 Subject: [PATCH 1/2] feat: add custom message type filtering to hook system (Phase 3, Task Group 3) - Add custom_message_type field to HookFilter struct - Implement extract_custom_message_type() for namespace detection - Update matches_filter() to check custom types against expected types - Add unit tests for custom type extraction and filtering (6 new tests) - Add integration test for E2E custom type routing - Fix clippy warnings in existing filter tests Phase 3 Task Group 3 complete. Hook system now supports filtering on all 12 custom XEP message types (thought, tool_call, tool_result, todo, code_change, integration, review_comment, work_available, question, answer, status_update, feature_complete) defined in Phase 3.1. Test Results: - 20 unit tests pass (chattermax-server) - 1 integration test passes - 15 chattermax-core tests pass - Zero compiler warnings - Clippy passes with -D warnings - Code formatted with cargo fmt --- chattermax-server/src/hooks/config.rs | 3 + chattermax-server/src/hooks/filter.rs | 175 ++++++++++++++++++++++++- chattermax-server/tests/integration.rs | 93 +++++++++++++ 3 files changed, 267 insertions(+), 4 deletions(-) diff --git a/chattermax-server/src/hooks/config.rs b/chattermax-server/src/hooks/config.rs index 16c4069..1a70f76 100644 --- a/chattermax-server/src/hooks/config.rs +++ b/chattermax-server/src/hooks/config.rs @@ -63,6 +63,9 @@ pub struct HookFilter { /// Composite filter logic #[serde(default)] pub composite: Option, + + /// Match on custom XEP message type (e.g., "tool_call", "thought") + pub custom_message_type: Option, } /// Composite filter with AND/OR/NOT logic diff --git a/chattermax-server/src/hooks/filter.rs b/chattermax-server/src/hooks/filter.rs index 1780459..12af148 100644 --- a/chattermax-server/src/hooks/filter.rs +++ b/chattermax-server/src/hooks/filter.rs @@ -4,10 +4,36 @@ use crate::hooks::HookError; use crate::hooks::HookFilter; use crate::hooks::config::CompositeFilter; use crate::hooks::errors::Result; +use chattermax_core::types::MessageType; use minidom::Element; use regex::Regex; use std::collections::HashMap; +/// Extract custom message type from XMPP message element +pub fn extract_custom_message_type(message: &Element) -> Option { + // Check for custom namespace children + for child in message.children() { + let ns = child.ns(); + match ns.as_str() { + "jabber:x:chibi:thought" => return Some(MessageType::Thought), + "jabber:x:chibi:tool_call" => return Some(MessageType::ToolCall), + "jabber:x:chibi:tool_result" => return Some(MessageType::ToolResult), + "jabber:x:chibi:todo" => return Some(MessageType::Todo), + "jabber:x:chibi:code_change" => return Some(MessageType::CodeChange), + "jabber:x:chibi:integration" => return Some(MessageType::Integration), + "jabber:x:chibi:review_comment" => return Some(MessageType::ReviewComment), + "jabber:x:chibi:work_available" => return Some(MessageType::WorkAvailable), + "jabber:x:chibi:question" => return Some(MessageType::Question), + "jabber:x:chibi:answer" => return Some(MessageType::Answer), + "jabber:x:chibi:status_update" => return Some(MessageType::StatusUpdate), + "jabber:x:chibi:feature_complete" => return Some(MessageType::FeatureComplete), + _ => continue, + } + } + + None +} + /// Extract variables from an XMPP message pub fn extract_variables(message: &Element) -> HashMap { let mut vars = HashMap::new(); @@ -80,6 +106,39 @@ pub fn matches_filter(message: &Element, filter: &HookFilter) -> Result { } } + // Check custom message type + if let Some(ref custom_type_str) = filter.custom_message_type { + let expected_type = match custom_type_str.as_str() { + "thought" => MessageType::Thought, + "tool_call" => MessageType::ToolCall, + "tool_result" => MessageType::ToolResult, + "todo" => MessageType::Todo, + "code_change" => MessageType::CodeChange, + "integration" => MessageType::Integration, + "review_comment" => MessageType::ReviewComment, + "work_available" => MessageType::WorkAvailable, + "question" => MessageType::Question, + "answer" => MessageType::Answer, + "status_update" => MessageType::StatusUpdate, + "feature_complete" => MessageType::FeatureComplete, + _ => { + return Err(HookError::FilterError(format!( + "Unknown custom message type: {}", + custom_type_str + ))); + } + }; + + if let Some(actual_type) = extract_custom_message_type(message) { + if actual_type != expected_type { + return Ok(false); + } + } else { + // Message doesn't have a custom type, so it can't match + return Ok(false); + } + } + // Check composite filters if let Some(ref composite) = filter.composite && !matches_composite(message, composite)? @@ -149,8 +208,10 @@ mod tests { .attr("type", "groupchat") .build(); - let mut filter = HookFilter::default(); - filter.room_pattern = Some("^feature/.*".to_string()); + let filter = HookFilter { + room_pattern: Some("^feature/.*".to_string()), + ..Default::default() + }; assert!(matches_filter(&msg, &filter).unwrap()); } @@ -162,8 +223,10 @@ mod tests { .attr("type", "groupchat") .build(); - let mut filter = HookFilter::default(); - filter.room_pattern = Some("^feature/.*".to_string()); + let filter = HookFilter { + room_pattern: Some("^feature/.*".to_string()), + ..Default::default() + }; assert!(!matches_filter(&msg, &filter).unwrap()); } @@ -214,4 +277,108 @@ mod tests { assert!(matches_composite(&msg, &composite).unwrap()); } + + #[test] + fn test_extract_custom_message_type_tool_call() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("from", "alice@localhost") + .attr("to", "bob@localhost") + .attr("type", "chat") + .append( + minidom::Element::builder("tool_call", "jabber:x:chibi:tool_call") + .append( + minidom::Element::builder("tool_name", "jabber:x:chibi:tool_call") + .append("run_tests") + .build(), + ) + .build(), + ) + .build(); + + let result = extract_custom_message_type(&message); + assert_eq!(result, Some(MessageType::ToolCall)); + } + + #[test] + fn test_extract_custom_message_type_none() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("from", "alice@localhost") + .attr("to", "bob@localhost") + .attr("type", "chat") + .append( + minidom::Element::builder("body", "jabber:client") + .append("Hello") + .build(), + ) + .build(); + + let result = extract_custom_message_type(&message); + assert_eq!(result, None); + } + + #[test] + fn test_filter_custom_message_type_match() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("type", "groupchat") + .append( + minidom::Element::builder("thought", "jabber:x:chibi:thought") + .append( + minidom::Element::builder("content", "jabber:x:chibi:thought") + .append("Thinking about the problem") + .build(), + ) + .build(), + ) + .build(); + + let filter = HookFilter { + custom_message_type: Some("thought".to_string()), + ..Default::default() + }; + + assert!(matches_filter(&message, &filter).unwrap()); + } + + #[test] + fn test_filter_custom_message_type_no_match() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("type", "groupchat") + .append(minidom::Element::builder("thought", "jabber:x:chibi:thought").build()) + .build(); + + let filter = HookFilter { + custom_message_type: Some("tool_call".to_string()), + ..Default::default() + }; + + assert!(!matches_filter(&message, &filter).unwrap()); + } + + #[test] + fn test_filter_custom_message_type_thought() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("from", "agent1@localhost") + .append(minidom::Element::builder("thought", "jabber:x:chibi:thought").build()) + .build(); + + let filter = HookFilter { + custom_message_type: Some("thought".to_string()), + ..Default::default() + }; + + assert!(matches_filter(&message, &filter).unwrap()); + } + + #[test] + fn test_filter_custom_message_type_unknown() { + let message = minidom::Element::builder("message", "jabber:client").build(); + + let filter = HookFilter { + custom_message_type: Some("unknown_type".to_string()), + ..Default::default() + }; + + let result = matches_filter(&message, &filter); + assert!(result.is_err()); + } } diff --git a/chattermax-server/tests/integration.rs b/chattermax-server/tests/integration.rs index d09e900..b85bd46 100644 --- a/chattermax-server/tests/integration.rs +++ b/chattermax-server/tests/integration.rs @@ -585,3 +585,96 @@ async fn test_presence_broadcast() { // No response expected for presence - just verify no crash tokio::time::sleep(Duration::from_millis(100)).await; } + +// ============================================================================ +// Custom Message Type Tests +// ============================================================================ + +#[tokio::test] +async fn test_custom_message_type_hook_filtering() { + use chattermax_server::hooks::HookFilter; + use chattermax_server::hooks::filter::{extract_custom_message_type, matches_filter}; + + // Create a thought message with custom namespace + let thought_message = minidom::Element::builder("message", "jabber:client") + .attr("from", "alice@localhost") + .attr("to", "agents@localhost") + .attr("type", "groupchat") + .append( + minidom::Element::builder("thought", "jabber:x:chibi:thought") + .append( + minidom::Element::builder("content", "jabber:x:chibi:thought") + .append("Analyzing the problem space") + .build(), + ) + .build(), + ) + .build(); + + // Extract should find the thought type + let extracted_type = extract_custom_message_type(&thought_message); + assert_eq!( + extracted_type, + Some(chattermax_core::types::MessageType::Thought), + "Should extract Thought message type" + ); + + // Filter with matching custom type should pass + let thought_filter = HookFilter { + custom_message_type: Some("thought".to_string()), + ..Default::default() + }; + + assert!( + matches_filter(&thought_message, &thought_filter).unwrap(), + "Thought message should match thought filter" + ); + + // Filter with non-matching custom type should fail + let tool_call_filter = HookFilter { + custom_message_type: Some("tool_call".to_string()), + ..Default::default() + }; + + assert!( + !matches_filter(&thought_message, &tool_call_filter).unwrap(), + "Thought message should not match tool_call filter" + ); + + // Create a tool_call message + let tool_call_message = minidom::Element::builder("message", "jabber:client") + .attr("from", "agent1@localhost") + .attr("to", "agents@localhost") + .attr("type", "groupchat") + .append( + minidom::Element::builder("tool_call", "jabber:x:chibi:tool_call") + .append( + minidom::Element::builder("tool_name", "jabber:x:chibi:tool_call") + .append("run_tests") + .build(), + ) + .append(minidom::Element::builder("arguments", "jabber:x:chibi:tool_call").build()) + .build(), + ) + .build(); + + // Extract should find the tool_call type + let extracted_type = extract_custom_message_type(&tool_call_message); + assert_eq!( + extracted_type, + Some(chattermax_core::types::MessageType::ToolCall), + "Should extract ToolCall message type" + ); + + // tool_call filter should match + assert!( + matches_filter(&tool_call_message, &tool_call_filter).unwrap(), + "ToolCall message should match tool_call filter" + ); + + // thought filter should not match + assert!( + !matches_filter(&tool_call_message, &thought_filter).unwrap(), + "ToolCall message should not match thought filter" + ); +} From 9bc7b2914805472fb833ec7d6b3bb67dc300bca0 Mon Sep 17 00:00:00 2001 From: terra tauri Date: Sun, 1 Feb 2026 23:28:56 -0800 Subject: [PATCH 2/2] feat: extract chattermax-client library with public API - Created Client struct with async connect, send_message, send_custom_message - Added ClientBuilder for ergonomic configuration - Created lib.rs exposing public API with re-exported core types - Updated error handling to use library-specific ClientError - Made Connection module internal (pub(crate)) with send_raw method - Prepared foundation for CLI to use library (Phase 3 Task Group 4) Library features: - ClientBuilder::new(server, username, password) with port/tls options - Client::send_message() for plain text - Client::send_custom_message() for 12 XEP types - Client::join_room/leave_room/close for room management - Graceful error handling with thiserror Chibi integration ready: Library can be used as dependency to send/receive structured inter-agent messages over XMPP. --- chattermax-client/src/builder.rs | 55 +++++++++++ chattermax-client/src/client.rs | 140 ++++++++++++++++++++++++++++ chattermax-client/src/connection.rs | 27 ++++-- chattermax-client/src/error.rs | 48 ++++++---- chattermax-client/src/lib.rs | 70 ++++++++++++++ 5 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 chattermax-client/src/builder.rs create mode 100644 chattermax-client/src/client.rs create mode 100644 chattermax-client/src/lib.rs diff --git a/chattermax-client/src/builder.rs b/chattermax-client/src/builder.rs new file mode 100644 index 0000000..540aacf --- /dev/null +++ b/chattermax-client/src/builder.rs @@ -0,0 +1,55 @@ +//! Client builder for ergonomic configuration + +use crate::client::Client; +use crate::error::Result; + +/// Builder for constructing a configured XMPP client +#[derive(Debug, Clone)] +pub struct ClientBuilder { + server: String, + port: u16, + username: String, + password: String, + use_tls: bool, +} + +impl ClientBuilder { + /// Create a new client builder with required parameters + pub fn new( + server: impl Into, + username: impl Into, + password: impl Into, + ) -> Self { + Self { + server: server.into(), + port: 5222, // Default XMPP port + username: username.into(), + password: password.into(), + use_tls: false, // Default to plain connection (dev environments) + } + } + + /// Set the server port (default: 5222) + pub fn port(mut self, port: u16) -> Self { + self.port = port; + self + } + + /// Enable TLS for connection (default: false) + pub fn use_tls(mut self, use_tls: bool) -> Self { + self.use_tls = use_tls; + self + } + + /// Build and connect the client + pub async fn connect(self) -> Result { + Client::connect( + &self.server, + self.port, + &self.username, + &self.password, + self.use_tls, + ) + .await + } +} diff --git a/chattermax-client/src/client.rs b/chattermax-client/src/client.rs new file mode 100644 index 0000000..2eaf9c2 --- /dev/null +++ b/chattermax-client/src/client.rs @@ -0,0 +1,140 @@ +//! High-level XMPP client API + +use crate::connection::Connection; +use crate::error::{ClientError, Result}; +use chattermax_core::stream::ns; +use chattermax_core::types::Message; +use minidom::Element; + +/// High-level XMPP client for sending and receiving messages +pub struct Client { + connection: Connection, +} + +impl Client { + /// Connect to an XMPP server and authenticate + /// + /// For most use cases, prefer using `ClientBuilder` instead: + /// ```rust,no_run + /// # use chattermax_client::ClientBuilder; + /// # async fn example() -> chattermax_client::error::Result<()> { + /// let client = ClientBuilder::new("localhost", "user", "pass") + /// .port(5222) + /// .connect() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub(crate) async fn connect( + server: &str, + port: u16, + username: &str, + password: &str, + use_tls: bool, + ) -> Result { + let connection = Connection::connect(server, port, username, password, use_tls).await?; + Ok(Self { connection }) + } + + /// Send a plain text message to a JID + pub async fn send_message(&mut self, to: &str, body: &str) -> Result<()> { + self.connection.send_message(to, body).await + } + + /// Send a custom XEP message (one of the 12 structured types) + /// + /// # Example + /// ```rust,no_run + /// # use chattermax_client::{ClientBuilder, Message}; + /// # use chattermax_core::types::{ToolCall, Metadata}; + /// # async fn example() -> chattermax_client::error::Result<()> { + /// let mut client = ClientBuilder::new("localhost", "user", "pass").connect().await?; + /// + /// let tool_call = ToolCall { + /// tool_name: "run_tests".to_string(), + /// arguments: vec![("commit".to_string(), "abc123".to_string())], + /// metadata: Metadata::default(), + /// }; + /// + /// client.send_custom_message("room@conference.localhost", &Message::ToolCall(tool_call)).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_custom_message(&mut self, to: &str, message: &Message) -> Result<()> { + // Serialize message to XML element + let custom_elem = chattermax_core::types::to_xml(message)?; + + // Create XMPP message stanza with custom type embedded + let msg_id = uuid::Uuid::new_v4().to_string(); + let msg_elem = Element::builder("message", ns::CLIENT) + .attr("to", to) + .attr("type", "groupchat") + .attr("id", &msg_id) + .append( + Element::builder("body", ns::CLIENT) + .append(format!( + "[{}] {}", + message.message_type().as_str(), + self.message_summary(message) + )) + .build(), + ) + .append(custom_elem) + .build(); + + // Send the stanza + let mut buf = Vec::new(); + msg_elem.write_to(&mut buf)?; + let msg_xml = String::from_utf8(buf) + .map_err(|e| ClientError::ProtocolError(format!("Invalid UTF-8 in message: {}", e)))?; + + self.connection.send_raw(&msg_xml).await?; + + Ok(()) + } + + /// Helper to create human-readable summary for message body (backward compatibility) + fn message_summary(&self, message: &Message) -> String { + match message { + Message::Thought(t) => t.content.chars().take(60).collect(), + Message::ToolCall(t) => format!("Tool call: {}", t.tool_name), + Message::ToolResult(t) => format!( + "Tool result: {} ({})", + t.tool_name, + if t.success { "success" } else { "failed" } + ), + Message::Todo(t) => format!("Todo: {}", t.title), + Message::CodeChange(c) => format!("Code change: {}", c.file_path), + Message::Integration(i) => { + format!("Integration: {} - {}", i.integration_name, i.event_type) + } + Message::ReviewComment(r) => format!( + "Review: {} - {}", + r.file_path, + r.comment.chars().take(40).collect::() + ), + Message::WorkAvailable(w) => format!("Work available: {}", w.work_type), + Message::Question(q) => q.question.chars().take(60).collect(), + Message::Answer(a) => a.answer.chars().take(60).collect(), + Message::StatusUpdate(s) => s.status.clone(), + Message::FeatureComplete(f) => format!("Feature complete: {}", f.feature_name), + } + } + + /// Join a multi-user chat room + pub async fn join_room(&mut self, room_jid: &str, nickname: &str) -> Result<()> { + self.connection.send_join_presence(room_jid, nickname).await + } + + /// Leave a multi-user chat room + pub async fn leave_room(&mut self, room_jid: &str, nickname: &str) -> Result<()> { + self.connection + .send_leave_presence(room_jid, nickname) + .await + } + + /// Close the connection gracefully + pub async fn close(mut self) -> Result<()> { + self.connection.close().await + } +} diff --git a/chattermax-client/src/connection.rs b/chattermax-client/src/connection.rs index 384687c..e26a8bb 100644 --- a/chattermax-client/src/connection.rs +++ b/chattermax-client/src/connection.rs @@ -3,7 +3,7 @@ //! Handles establishing and maintaining connections to XMPP servers, //! including stream negotiation, SASL authentication, and TLS support. -use anyhow::{Result, anyhow}; +use crate::error::{ClientError, Result}; use base64::{Engine, engine::general_purpose}; use chattermax_core::stream::ns; use minidom::Element; @@ -29,8 +29,8 @@ pub enum ConnectionState { } /// XMPP client connection -pub struct Connection { - socket: TcpStream, +pub(crate) struct Connection { + pub(crate) socket: TcpStream, state: ConnectionState, jid: Option, xml_buffer: String, @@ -38,7 +38,7 @@ pub struct Connection { impl Connection { /// Create a new connection to an XMPP server - pub async fn connect( + pub(crate) async fn connect( server: &str, port: u16, username: &str, @@ -70,7 +70,10 @@ impl Connection { // Wait for success let response = conn.read_element().await?; if response.name() != "success" { - return Err(anyhow!("Authentication failed: {:?}", response)); + return Err(ClientError::AuthenticationFailed(format!( + "SASL authentication rejected: {:?}", + response + ))); } info!("Authentication successful"); conn.state = ConnectionState::Authenticated; @@ -156,7 +159,9 @@ impl Connection { // Read bind response let response = self.read_element().await?; if response.attr("type") != Some("result") { - return Err(anyhow!("Resource binding failed")); + return Err(ClientError::ProtocolError( + "Resource binding failed".to_string(), + )); } // Extract and store JID @@ -181,7 +186,7 @@ impl Connection { // Need more data let n = self.socket.read(&mut buf).await?; if n == 0 { - return Err(anyhow!("Connection closed by server")); + return Err(ClientError::ConnectionClosed); } self.xml_buffer @@ -320,6 +325,14 @@ impl Connection { Ok(()) } + /// Send raw XML data to the server + #[allow(dead_code)] + pub(crate) async fn send_raw(&mut self, data: &str) -> Result<()> { + self.socket.write_all(data.as_bytes()).await?; + self.socket.flush().await?; + Ok(()) + } + /// Close the connection gracefully pub async fn close(&mut self) -> Result<()> { let close_stream = ""; diff --git a/chattermax-client/src/error.rs b/chattermax-client/src/error.rs index ab58561..210fca0 100644 --- a/chattermax-client/src/error.rs +++ b/chattermax-client/src/error.rs @@ -1,31 +1,45 @@ -//! Error types for Chattermax CLI +//! Client library error types +use std::io; +use std::string::FromUtf8Error; use thiserror::Error; -#[allow(dead_code)] -#[derive(Error, Debug)] +/// Errors that can occur when using the chattermax client library +#[derive(Debug, Error)] pub enum ClientError { - #[error("connection failed: {0}")] - ConnectionFailed(String), + /// Connection to XMPP server failed + #[error("Connection failed: {0}")] + ConnectionFailed(#[from] io::Error), - #[error("authentication failed: {0}")] + /// Authentication with XMPP server failed + #[error("Authentication failed: {0}")] AuthenticationFailed(String), - #[error("server error: {0}")] - ServerError(String), + /// XML parsing error + #[error("XML parsing error: {0}")] + XmlError(#[from] minidom::Error), - #[error("invalid JID: {0}")] - InvalidJid(String), + /// XMPP protocol error + #[error("XMPP protocol error: {0}")] + ProtocolError(String), - #[error("IO error: {0}")] - IoError(#[from] std::io::Error), + /// Message serialization/deserialization error + #[error("Message serialization error: {0}")] + SerializationError(#[from] chattermax_core::error::Error), - #[error("protocol error: {0}")] - ProtocolError(String), + /// Invalid configuration provided + #[error("Invalid configuration: {0}")] + #[allow(dead_code)] + InvalidConfig(String), + + /// Connection already closed + #[error("Connection is closed")] + ConnectionClosed, - #[error("unexpected response: {0}")] - UnexpectedResponse(String), + /// UTF-8 encoding error + #[error("UTF-8 encoding error: {0}")] + Utf8Error(#[from] FromUtf8Error), } -#[allow(dead_code)] +/// Result type alias for client operations pub type Result = std::result::Result; diff --git a/chattermax-client/src/lib.rs b/chattermax-client/src/lib.rs new file mode 100644 index 0000000..562fd52 --- /dev/null +++ b/chattermax-client/src/lib.rs @@ -0,0 +1,70 @@ +//! Chattermax XMPP Client Library +//! +//! A Rust library for connecting to XMPP servers and sending/receiving messages, +//! with first-class support for custom XEP message types for autonomous multi-agent workflows. +//! +//! # Quick Start +//! +//! ```rust,no_run +//! use chattermax_client::ClientBuilder; +//! +//! #[tokio::main] +//! async fn main() -> chattermax_client::error::Result<()> { +//! let mut client = ClientBuilder::new("localhost", "alice", "password") +//! .port(5222) +//! .connect() +//! .await?; +//! +//! client.send_message("bob@localhost", "Hello from Chattermax!").await?; +//! client.close().await?; +//! Ok(()) +//! } +//! ``` +//! +//! # Custom XEP Message Types +//! +//! Chattermax supports 12 structured message types for inter-agent communication: +//! +//! ```rust,no_run +//! use chattermax_client::ClientBuilder; +//! use chattermax_client::{Message, ToolCall, Metadata}; +//! +//! #[tokio::main] +//! async fn main() -> chattermax_client::error::Result<()> { +//! let mut client = ClientBuilder::new("localhost", "agent-a", "password") +//! .connect() +//! .await?; +//! +//! let tool_call = ToolCall { +//! tool_name: "run_tests".to_string(), +//! arguments: vec![("commit".to_string(), "abc123".to_string())], +//! metadata: Metadata::default(), +//! }; +//! +//! client.send_custom_message( +//! "agents@conference.localhost", +//! &Message::ToolCall(tool_call) +//! ).await?; +//! +//! Ok(()) +//! } +//! ``` + +// Public modules +pub mod builder; +pub mod client; +pub mod error; + +// Internal modules (not part of public API) +mod connection; + +// Re-exports for convenience +pub use builder::ClientBuilder; +pub use client::Client; +pub use error::{ClientError, Result}; + +// Re-export core types so library users don't need to depend on chattermax-core directly +pub use chattermax_core::types::{ + Answer, CodeChange, FeatureComplete, Integration, Message, MessageType, Metadata, Question, + ReviewComment, StatusUpdate, Thought, Todo, ToolCall, ToolResult, WorkAvailable, +};