oxicode_sdk/
snapcompact_compactor.rs1use std::pin::Pin;
14use std::sync::Arc;
15
16use oxicode_ai::Message;
17use oxicode_ai::compaction::{CompactedContext, CompactionError, CompactionMetadata, Compactor};
18use oxicode_snapcompact::Shape;
19
20pub struct SnapcompactCompactor {
35 shape: Option<Shape>,
38 frame_chars: usize,
42 max_frames: u32,
44}
45
46impl Default for SnapcompactCompactor {
47 fn default() -> Self {
48 Self {
49 shape: None,
50 frame_chars: 4000,
51 max_frames: 8,
52 }
53 }
54}
55
56impl SnapcompactCompactor {
57 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn with_shape(mut self, shape: Shape) -> Self {
64 self.shape = Some(shape);
65 self
66 }
67
68 pub fn with_frame_chars(mut self, frame_chars: usize) -> Self {
70 self.frame_chars = frame_chars.max(64);
71 self
72 }
73
74 pub fn with_max_frames(mut self, max_frames: u32) -> Self {
76 self.max_frames = max_frames;
77 self
78 }
79
80 pub fn shape(&self) -> Option<&Shape> {
82 self.shape.as_ref()
83 }
84}
85
86impl Compactor for SnapcompactCompactor {
87 fn estimate_tokens(&self, messages: &[Message]) -> usize {
88 messages
94 .iter()
95 .map(|m| m.text_content().map(|t| t.len() / 4).unwrap_or(0))
96 .sum()
97 }
98
99 fn compact<'a>(
100 &'a self,
101 messages: &'a [Message],
102 instruction: Option<&'a str>,
103 ) -> Pin<
104 Box<
105 dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
106 >,
107 > {
108 Box::pin(async move {
109 if messages.is_empty() {
110 return Err(CompactionError::NoMessagesToCompact);
111 }
112
113 let mut text = String::new();
119 if let Some(instr) = instruction {
120 text.push_str(&format!("[instruction] {instr}\n\n"));
121 }
122 text.push_str(&oxicode_snapcompact::serialize_conversation(
123 &flatten_messages(messages),
124 self.frame_chars,
125 ));
126
127 let frame_count = max_frame_count(text.chars().count(), self.frame_chars);
128 let frames_to_render = frame_count.min(self.max_frames as usize).max(1);
129
130 let original_tokens = self.estimate_tokens(messages);
131
132 let mut frames: Vec<(u32, Vec<u8>)> = Vec::with_capacity(frames_to_render);
134 let mut idx: u32 = 0;
135 'outer: for chunk in chunks(&text, self.frame_chars).take(frames_to_render) {
136 let prep = oxicode_snapcompact::prepare(&chunk, 200_000, self.frame_chars);
137 let opts = oxicode_snapcompact::CompactOptions {
138 shape: self.shape.clone(),
139 model_id: String::new(),
140 max_frames: 1,
141 };
142 let result = oxicode_snapcompact::compact(&prep, &opts);
143 if result.frames.is_empty() {
144 return Err(CompactionError::LlmError(format!(
145 "snapcompact produced no frames for chunk {idx}"
146 )));
147 }
148 for f in result.frames {
149 if f.bytes.is_empty() {
150 return Err(CompactionError::LlmError(format!(
151 "snapcompact frame {} returned empty bytes (render failure)",
152 f.index
153 )));
154 }
155 frames.push((idx, f.bytes));
156 idx += 1;
157 if idx as usize == self.max_frames as usize {
158 break 'outer;
159 }
160 }
161 }
162
163 let metadata = CompactionMetadata::new(
168 original_tokens,
169 estimate_frame_tokens(frames.len()),
170 frames.len(),
171 0,
172 0.0, );
174
175 let mut context = CompactedContext::new(
176 format!("[snapcompact] {} frames", frames.len()),
177 Vec::new(),
178 frames.len(),
179 metadata,
180 );
181 context.frames = Some(Arc::new(frames));
182 Ok(context)
183 })
184 }
185}
186
187fn estimate_frame_tokens(frames: usize) -> usize {
193 frames.saturating_mul(1500)
194}
195
196fn max_frame_count(chars: usize, frame_chars: usize) -> usize {
197 if frame_chars == 0 {
198 return 0;
199 }
200 chars.div_ceil(frame_chars)
201}
202
203fn chunks(text: &str, frame_chars: usize) -> impl Iterator<Item = String> + '_ {
204 let mut remaining = text;
205 std::iter::from_fn(move || {
206 if remaining.is_empty() {
207 return None;
208 }
209 let n = remaining.chars().count().min(frame_chars);
211 let cut = remaining
212 .char_indices()
213 .nth(n)
214 .map(|(idx, _)| idx)
215 .unwrap_or(remaining.len());
216 let (head, tail) = remaining.split_at(cut);
217 remaining = tail;
218 Some(head.to_string())
219 })
220}
221
222fn flatten_messages(messages: &[Message]) -> String {
226 let mut out = String::new();
227 for m in messages {
228 let role = match m {
229 Message::User(_) => "user",
230 Message::Assistant(_) => "assistant",
231 Message::ToolResult(_) => "tool",
232 };
233 let text = m.text_content().unwrap_or_default();
234 if !text.is_empty() {
235 if !out.is_empty() {
236 out.push('\n');
237 }
238 out.push_str(role);
239 out.push_str(": ");
240 out.push_str(&text);
241 }
242 }
243 out
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn default_construction() {
252 let c = SnapcompactCompactor::new();
253 assert!(c.shape().is_none());
254 assert_eq!(c.frame_chars, 4000);
255 assert_eq!(c.max_frames, 8);
256 }
257
258 #[test]
259 fn shape_pinning_works() {
260 let shape = oxicode_snapcompact::SHAPES[0].clone();
261 let c = SnapcompactCompactor::new().with_shape(shape.clone());
262 assert!(c.shape().is_some());
263 assert_eq!(c.shape().unwrap().name, shape.name);
264 }
265
266 #[test]
267 fn frame_chars_floor() {
268 let c = SnapcompactCompactor::new().with_frame_chars(0);
269 assert_eq!(c.frame_chars, 64);
271 }
272
273 #[test]
274 fn empty_messages_returns_error() {
275 let rt = tokio::runtime::Builder::new_current_thread()
276 .enable_all()
277 .build()
278 .unwrap();
279 let compactor = SnapcompactCompactor::new();
280 let err = rt
281 .block_on(async { compactor.compact(&[], None).await })
282 .unwrap_err();
283 assert!(matches!(err, CompactionError::NoMessagesToCompact));
284 }
285
286 #[test]
287 fn compact_real_messages_produces_png_frames() {
288 let rt = tokio::runtime::Builder::new_current_thread()
289 .enable_all()
290 .build()
291 .unwrap();
292 let messages = vec![Message::user(
293 "Hello world. This is a test message that should be rendered as a PNG frame by snapcompact.",
294 )];
295 let compactor = SnapcompactCompactor::new();
296 let result = rt
297 .block_on(async { compactor.compact(&messages, None).await })
298 .expect("compaction should succeed");
299 let frames = result.frames.as_ref().expect("frames should be attached");
300 assert!(!frames.is_empty(), "should produce ≥1 frame");
301 for (idx, bytes) in frames.iter() {
302 assert!(!bytes.is_empty(), "frame {idx} must be non-empty");
303 assert_eq!(
305 &bytes[..8],
306 &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
307 "frame {idx} must be PNG"
308 );
309 }
310 }
311}