1use std::path::Path;
2use std::sync::{Arc, Mutex};
3
4use crate::ast_parser::AstParser;
5use crate::budget_tracker::{BudgetTracker, UsageReport};
6use crate::cache_manager::CacheManager;
7use crate::confidence_router::ConfidenceRouter;
8use crate::cost_calculator::{CostCalculator, SessionCostSummary};
9use crate::ctx_format::CtxFormat;
10use crate::error::{Result, SqzError};
11use crate::model_router::ModelRouter;
12use crate::pin_manager::PinManager;
13use crate::pipeline::CompressionPipeline;
14use crate::plugin_api::PluginLoader;
15use crate::preset::{Preset, PresetParser};
16use crate::session_store::{SessionStore, SessionSummary};
17use crate::terse_mode::TerseMode;
18use crate::types::{CompressedContent, PinEntry, Provenance, SessionId};
19use crate::verifier::Verifier;
20
21pub struct SqzEngine {
32 preset: Arc<Mutex<Preset>>,
34 pipeline: Arc<Mutex<CompressionPipeline>>,
35 model_router: Arc<Mutex<ModelRouter>>,
36
37 session_store: SessionStore,
39 cache_manager: CacheManager,
40 budget_tracker: BudgetTracker,
41 cost_calculator: CostCalculator,
42 ast_parser: AstParser,
43 terse_mode: TerseMode,
44 pin_manager: PinManager,
45 confidence_router: ConfidenceRouter,
46 _plugin_loader: PluginLoader,
47}
48
49impl SqzEngine {
50 pub fn new() -> Result<Self> {
55 let preset = Preset::default();
56 let store_path = Self::default_store_path();
57 Self::with_preset_and_store(preset, &store_path)
58 }
59
60 fn default_store_path() -> std::path::PathBuf {
63 if let Some(home) = dirs_next::home_dir() {
64 let sqz_dir = home.join(".sqz");
65 if std::fs::create_dir_all(&sqz_dir).is_ok() {
66 #[cfg(unix)]
69 {
70 use std::os::unix::fs::PermissionsExt;
71 let _ = std::fs::set_permissions(
72 &sqz_dir,
73 std::fs::Permissions::from_mode(0o700),
74 );
75 }
76 return sqz_dir.join("sessions.db");
77 }
78 }
79 let dir = std::env::temp_dir();
81 dir.join(format!(
82 "sqz_session_{}_{}.db",
83 std::process::id(),
84 std::time::SystemTime::now()
85 .duration_since(std::time::UNIX_EPOCH)
86 .map(|d| d.as_nanos())
87 .unwrap_or(0)
88 ))
89 }
90
91 pub fn with_preset_and_store(preset: Preset, store_path: &Path) -> Result<Self> {
97 let pipeline = CompressionPipeline::new(&preset);
98 let window_size = preset.budget.default_window_size;
99
100 let session_store = SessionStore::open_or_create(store_path)?;
102 let cache_store = SessionStore::open_or_create(store_path)?;
103 let pin_store = SessionStore::open_or_create(store_path)?;
104
105 Ok(SqzEngine {
106 preset: Arc::new(Mutex::new(preset.clone())),
107 pipeline: Arc::new(Mutex::new(pipeline)),
108 model_router: Arc::new(Mutex::new(ModelRouter::new(&preset))),
109 session_store,
110 cache_manager: CacheManager::new(cache_store, 512 * 1024 * 1024),
111 budget_tracker: BudgetTracker::new(window_size, &preset),
112 cost_calculator: CostCalculator::with_defaults(),
113 ast_parser: AstParser::new(),
114 terse_mode: TerseMode,
115 pin_manager: PinManager::new(pin_store),
116 confidence_router: ConfidenceRouter::new(),
117 _plugin_loader: PluginLoader::new(Path::new("plugins")),
118 })
119 }
120
121 pub fn compress(&self, input: &str) -> Result<CompressedContent> {
129 let preset = self.preset.lock()
130 .map_err(|_| SqzError::Other("preset lock poisoned".into()))?;
131 let pipeline = self.pipeline.lock()
132 .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
133 let ctx = crate::pipeline::SessionContext {
134 session_id: "engine".to_string(),
135 };
136
137 let mode = self.confidence_router.route(input);
139
140 if mode == crate::confidence_router::CompressionMode::Safe {
142 eprintln!("[sqz] fallback: safe mode — content classified as high-risk (stack trace / migration / secret)");
143 return self.compress_safe(input, &pipeline, &ctx);
144 }
145
146 let mut result = pipeline.compress(input, &ctx, &preset)?;
148
149 let verify = Verifier::verify(input, &result.data);
151 let fallback = verify.fallback_triggered;
152 result.verify = Some(verify);
153
154 if fallback && result.data != input {
156 eprintln!("[sqz] fallback: verifier confidence {:.2} below threshold — re-compressing in safe mode",
157 result.verify.as_ref().map(|v| v.confidence).unwrap_or(0.0));
158 let safe_result = self.compress_safe(input, &pipeline, &ctx)?;
159 return Ok(safe_result);
160 }
161
162 Ok(result)
163 }
164
165 pub fn compress_with_cache(&self, input: &str) -> Result<crate::cache_manager::CacheResult> {
181 let pipeline = self.pipeline.lock()
182 .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
183 self.cache_manager.get_or_compress(
189 std::path::Path::new(""),
190 input.as_bytes(),
191 &pipeline,
192 )
193 }
194
195 pub fn compress_with_cache_lossless(&self, input: &str) -> Result<crate::cache_manager::CacheResult> {
201 let pipeline = self.pipeline.lock()
202 .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
203 self.cache_manager.get_or_compress_lossless(
204 std::path::Path::new(""),
205 input.as_bytes(),
206 &pipeline,
207 )
208 }
209
210 pub fn compress_or_passthrough(&self, input: &str) -> CompressedContent {
218 match self.compress(input) {
219 Ok(result) => result,
220 Err(_) => {
221 let tokens = (input.len() as u32 + 3) / 4;
222 CompressedContent {
223 data: input.to_string(),
224 tokens_compressed: tokens,
225 tokens_original: tokens,
226 stages_applied: vec![],
227 compression_ratio: 1.0,
228 provenance: crate::types::Provenance::default(),
229 verify: None,
230 }
231 }
232 }
233 }
234
235 pub fn compress_with_mode(&self, input: &str, mode: crate::confidence_router::CompressionMode) -> Result<CompressedContent> {
241 let pipeline = self.pipeline.lock()
242 .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
243 let ctx = crate::pipeline::SessionContext {
244 session_id: "engine".to_string(),
245 };
246
247 match mode {
248 crate::confidence_router::CompressionMode::Safe => {
249 self.compress_safe(input, &pipeline, &ctx)
250 }
251 _ => {
252 drop(pipeline); self.compress(input)
255 }
256 }
257 }
258
259 fn compress_safe(
261 &self,
262 input: &str,
263 pipeline: &crate::pipeline::CompressionPipeline,
264 ctx: &crate::pipeline::SessionContext,
265 ) -> Result<CompressedContent> {
266 use crate::preset::{
267 CompressionConfig, CondenseConfig, CustomTransformsConfig, BudgetConfig,
268 ModelConfig, PresetMeta, TerseModeConfig, TerseLevel, ToolSelectionConfig,
269 };
270
271 let safe_preset = Preset {
272 preset: PresetMeta {
273 name: "safe".to_string(),
274 version: "1.0".to_string(),
275 description: "Safe fallback — minimal compression".to_string(),
276 },
277 compression: CompressionConfig {
278 stages: vec!["condense".to_string()],
279 keep_fields: None,
280 strip_fields: None,
281 condense: Some(CondenseConfig { enabled: true, max_repeated_lines: 3 }),
282 git_diff_fold: None,
283 strip_nulls: None,
284 flatten: None,
285 truncate_strings: None,
286 collapse_arrays: None,
287 custom_transforms: Some(CustomTransformsConfig { enabled: false }),
288 },
289 tool_selection: ToolSelectionConfig {
290 max_tools: 5,
291 similarity_threshold: 0.7,
292 default_tools: vec![],
293 },
294 budget: BudgetConfig {
295 warning_threshold: 0.70,
296 ceiling_threshold: 0.85,
297 default_window_size: 200_000,
298 agents: Default::default(),
299 },
300 terse_mode: TerseModeConfig { enabled: false, level: TerseLevel::Moderate },
301 model: ModelConfig {
302 family: "anthropic".to_string(),
303 primary: String::new(),
304 local: String::new(),
305 complexity_threshold: 0.4,
306 pricing: None,
307 },
308 };
309
310 let mut result = pipeline.compress(input, ctx, &safe_preset)?;
311 let verify = Verifier::verify(input, &result.data);
312 result.verify = Some(verify);
313 result.provenance = Provenance {
314 label: Some("safe-fallback".to_string()),
315 ..Default::default()
316 };
317 Ok(result)
318 }
319
320 pub fn compress_with_provenance(
322 &self,
323 input: &str,
324 provenance: Provenance,
325 ) -> Result<CompressedContent> {
326 let mut result = self.compress(input)?;
327 result.provenance = provenance;
328 Ok(result)
329 }
330
331 pub fn export_ctx(&self, session_id: &str) -> Result<String> {
333 let session = self.session_store.load_session(session_id.to_string())?;
334 CtxFormat::serialize(&session)
335 }
336
337 pub fn import_ctx(&self, ctx: &str) -> Result<SessionId> {
339 let session = CtxFormat::deserialize(ctx)?;
340 self.session_store.save_session(&session)
341 }
342
343 pub fn pin(&self, session_id: &str, turn_index: usize, reason: &str, tokens: u32) -> Result<PinEntry> {
345 self.pin_manager.pin(session_id, turn_index, reason, tokens)
346 }
347
348 pub fn unpin(&self, session_id: &str, turn_index: usize) -> Result<()> {
350 self.pin_manager.unpin(session_id, turn_index)
351 }
352
353 pub fn search_sessions(&self, query: &str) -> Result<Vec<SessionSummary>> {
355 self.session_store.search(query)
356 }
357
358 pub fn usage_report(&self, agent_id: &str) -> UsageReport {
360 self.budget_tracker.usage_report(agent_id.to_string())
361 }
362
363 pub fn cost_summary(&self, session_id: &str) -> Result<SessionCostSummary> {
365 let session = self.session_store.load_session(session_id.to_string())?;
366 Ok(self.cost_calculator.session_summary(&session))
367 }
368
369 pub fn reload_preset(&mut self, toml: &str) -> Result<()> {
371 let new_preset = PresetParser::parse(toml)?;
372 if let Ok(mut pipeline) = self.pipeline.lock() {
373 pipeline.reload_preset(&new_preset)?;
374 }
375 if let Ok(mut router) = self.model_router.lock() {
376 *router = ModelRouter::new(&new_preset);
377 }
378 if let Ok(mut preset) = self.preset.lock() {
379 *preset = new_preset;
380 }
381 Ok(())
382 }
383
384 pub fn watch_preset_file(&self, path: &Path) -> Result<notify::RecommendedWatcher> {
389 use notify::{Event, EventKind, RecursiveMode, Watcher};
390
391 let preset_arc = Arc::clone(&self.preset);
392 let pipeline_arc = Arc::clone(&self.pipeline);
393 let router_arc = Arc::clone(&self.model_router);
394 let watched_path = path.to_owned();
395
396 let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
397 if let Ok(event) = res {
398 if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
399 match std::fs::read_to_string(&watched_path) {
400 Ok(toml_str) => match PresetParser::parse(&toml_str) {
401 Ok(new_preset) => {
402 if let Ok(mut p) = pipeline_arc.lock() {
403 let _ = p.reload_preset(&new_preset);
404 }
405 if let Ok(mut r) = router_arc.lock() {
406 *r = ModelRouter::new(&new_preset);
407 }
408 if let Ok(mut pr) = preset_arc.lock() {
409 *pr = new_preset;
410 }
411 }
412 Err(e) => eprintln!("[sqz] invalid preset: {e}"),
413 },
414 Err(e) => eprintln!("[sqz] preset read error: {e}"),
415 }
416 }
417 }
418 })
419 .map_err(|e| SqzError::Other(format!("watcher error: {e}")))?;
420
421 watcher
422 .watch(path, RecursiveMode::NonRecursive)
423 .map_err(|e| SqzError::Other(format!("watch error: {e}")))?;
424
425 Ok(watcher)
426 }
427
428 pub fn session_store(&self) -> &SessionStore {
430 &self.session_store
431 }
432
433 pub fn cache_manager(&self) -> &CacheManager {
435 &self.cache_manager
436 }
437
438 pub fn ast_parser(&self) -> &AstParser {
440 &self.ast_parser
441 }
442
443 pub fn terse_mode(&self) -> &TerseMode {
445 &self.terse_mode
446 }
447
448 pub fn reorder_context(
454 &self,
455 sections: &mut Vec<crate::litm_positioner::ContextSection>,
456 strategy: crate::litm_positioner::LitmStrategy,
457 ) {
458 let positioner = crate::litm_positioner::LitmPositioner::new(strategy);
459 positioner.reorder(sections);
460 }
461
462 pub fn route_compression_mode(&self, content: &str) -> crate::confidence_router::CompressionMode {
465 self.confidence_router.route(content)
466 }
467}
468
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
474 use chrono::Utc;
475 use std::path::PathBuf;
476
477 fn make_session(id: &str) -> SessionState {
478 let now = Utc::now();
479 SessionState {
480 id: id.to_string(),
481 project_dir: PathBuf::from("/tmp/test"),
482 conversation: vec![],
483 corrections: CorrectionLog::default(),
484 pins: vec![],
485 learnings: vec![],
486 compressed_summary: "test session".to_string(),
487 budget: BudgetState {
488 window_size: 200_000,
489 consumed: 0,
490 pinned: 0,
491 model_family: ModelFamily::AnthropicClaude,
492 },
493 tool_usage: vec![],
494 created_at: now,
495 updated_at: now,
496 }
497 }
498
499 #[test]
500 fn test_engine_new() {
501 let engine = SqzEngine::new();
502 assert!(engine.is_ok(), "SqzEngine::new() should succeed");
503 }
504
505 #[test]
506 fn test_compress_or_passthrough_returns_result_on_valid_input() {
507 let engine = SqzEngine::new().unwrap();
508 let result = engine.compress_or_passthrough("hello world");
509 assert_eq!(result.data, "hello world");
510 assert!(result.tokens_original > 0);
511 }
512
513 #[test]
514 fn test_compress_or_passthrough_never_panics_on_empty() {
515 let engine = SqzEngine::new().unwrap();
516 let result = engine.compress_or_passthrough("");
517 assert_eq!(result.data, "");
518 assert_eq!(result.compression_ratio, 1.0);
519 }
520
521 #[test]
522 fn test_compress_or_passthrough_handles_json() {
523 let engine = SqzEngine::new().unwrap();
524 let result = engine.compress_or_passthrough(r#"{"key":"value"}"#);
525 assert!(!result.data.is_empty());
527 }
528
529 #[test]
530 fn test_compress_or_passthrough_handles_binary_garbage() {
531 let engine = SqzEngine::new().unwrap();
532 let garbage = "\x00\x01\x02\x7f invalid control chars \t\n\r";
534 let result = engine.compress_or_passthrough(garbage);
535 assert!(!result.data.is_empty());
536 }
537
538 #[test]
539 fn test_compress_plain_text() {
540 let engine = SqzEngine::new().unwrap();
541 let result = engine.compress("hello world");
542 assert!(result.is_ok());
543 assert_eq!(result.unwrap().data, "hello world");
544 }
545
546 #[test]
547 fn test_compress_json_applies_toon() {
548 let engine = SqzEngine::new().unwrap();
549 let result = engine.compress(r#"{"name":"Alice","age":30}"#).unwrap();
550 assert!(result.data.starts_with("TOON:"), "JSON should be TOON-encoded");
551 }
552
553 #[test]
554 fn test_export_import_ctx_round_trip() {
555 let dir = tempfile::tempdir().unwrap();
556 let store_path = dir.path().join("store.db");
557 let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
558
559 let session = make_session("sess-rt");
560 engine.session_store().save_session(&session).unwrap();
561
562 let ctx = engine.export_ctx("sess-rt").unwrap();
563 let imported_id = engine.import_ctx(&ctx).unwrap();
564 assert_eq!(imported_id, "sess-rt");
565 }
566
567 #[test]
568 fn test_search_sessions() {
569 let dir = tempfile::tempdir().unwrap();
570 let store_path = dir.path().join("store.db");
571 let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
572
573 let mut session = make_session("sess-search");
574 session.compressed_summary = "authentication refactor".to_string();
575 engine.session_store().save_session(&session).unwrap();
576
577 let results = engine.search_sessions("authentication").unwrap();
578 assert_eq!(results.len(), 1);
579 assert_eq!(results[0].id, "sess-search");
580 }
581
582 #[test]
583 fn test_usage_report_starts_at_zero() {
584 let engine = SqzEngine::new().unwrap();
585 let report = engine.usage_report("default");
586 assert_eq!(report.consumed, 0);
587 assert_eq!(report.available, report.allocated);
588 }
589
590 #[test]
591 fn test_cost_summary() {
592 let dir = tempfile::tempdir().unwrap();
593 let store_path = dir.path().join("store.db");
594 let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
595
596 let session = make_session("sess-cost");
597 engine.session_store().save_session(&session).unwrap();
598
599 let summary = engine.cost_summary("sess-cost").unwrap();
600 assert_eq!(summary.total_tokens, 0);
601 assert!((summary.total_usd - 0.0).abs() < f64::EPSILON);
602 }
603
604 #[test]
605 fn test_reload_preset_updates_state() {
606 let mut engine = SqzEngine::new().unwrap();
607 let toml = r#"
608[preset]
609name = "reloaded"
610version = "2.0"
611
612[compression]
613stages = []
614
615[tool_selection]
616max_tools = 5
617similarity_threshold = 0.7
618
619[budget]
620warning_threshold = 0.70
621ceiling_threshold = 0.85
622default_window_size = 200000
623
624[terse_mode]
625enabled = false
626level = "moderate"
627
628[model]
629family = "anthropic"
630primary = "claude-sonnet-4-20250514"
631complexity_threshold = 0.4
632"#;
633 assert!(engine.reload_preset(toml).is_ok());
634 let preset = engine.preset.lock().unwrap();
636 assert_eq!(preset.preset.name, "reloaded");
637 }
638
639 #[test]
640 fn test_reload_invalid_preset_returns_error() {
641 let mut engine = SqzEngine::new().unwrap();
642 let result = engine.reload_preset("not valid toml [[[");
643 assert!(result.is_err(), "invalid TOML should return error");
644 }
645
646 #[test]
647 fn test_export_nonexistent_session_returns_error() {
648 let engine = SqzEngine::new().unwrap();
649 let result = engine.export_ctx("does-not-exist");
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn test_import_invalid_ctx_returns_error() {
655 let engine = SqzEngine::new().unwrap();
656 let result = engine.import_ctx("not valid json {{{");
657 assert!(result.is_err());
658 }
659}