Skip to main content

sqz_engine/
engine.rs

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
21/// Top-level facade that wires all sqz_engine modules together.
22///
23/// # Concurrency design
24///
25/// `SqzEngine` is designed for single-threaded use on the main thread.
26/// The only cross-thread sharing happens during preset hot-reload: the
27/// file-watcher callback runs on a background thread and needs to update
28/// the preset, pipeline, and model router. These three fields are wrapped
29/// in `Arc<Mutex<>>` specifically for that purpose. All other fields are
30/// owned directly — no unnecessary synchronization.
31pub struct SqzEngine {
32    // --- Hot-reloadable state (shared with file-watcher thread) ---
33    preset: Arc<Mutex<Preset>>,
34    pipeline: Arc<Mutex<CompressionPipeline>>,
35    model_router: Arc<Mutex<ModelRouter>>,
36
37    // --- Single-owner state (no cross-thread sharing needed) ---
38    session_store: SessionStore,
39    #[allow(dead_code)] // used internally by compress pipeline; public API pending
40    cache_manager: CacheManager,
41    budget_tracker: BudgetTracker,
42    cost_calculator: CostCalculator,
43    ast_parser: AstParser,
44    terse_mode: TerseMode,
45    pin_manager: PinManager,
46    confidence_router: ConfidenceRouter,
47    _plugin_loader: PluginLoader,
48}
49
50impl SqzEngine {
51    /// Create a new engine with the default preset and a persistent session store.
52    ///
53    /// Sessions are stored in `~/.sqz/sessions.db` for cross-session continuity.
54    /// Falls back to a temp-file store if the home directory is unavailable.
55    pub fn new() -> Result<Self> {
56        let preset = Preset::default();
57        let store_path = Self::default_store_path();
58        Self::with_preset_and_store(preset, &store_path)
59    }
60
61    /// Resolve the default session store path: `~/.sqz/sessions.db`.
62    /// Falls back to a temp-file path if home dir is unavailable.
63    fn default_store_path() -> std::path::PathBuf {
64        if let Some(home) = dirs_next::home_dir() {
65            let sqz_dir = home.join(".sqz");
66            if std::fs::create_dir_all(&sqz_dir).is_ok() {
67                return sqz_dir.join("sessions.db");
68            }
69        }
70        // Fallback: temp dir with unique name
71        let dir = std::env::temp_dir();
72        dir.join(format!(
73            "sqz_session_{}_{}.db",
74            std::process::id(),
75            std::time::SystemTime::now()
76                .duration_since(std::time::UNIX_EPOCH)
77                .map(|d| d.as_nanos())
78                .unwrap_or(0)
79        ))
80    }
81
82    /// Create with a custom preset and a file-backed session store.
83    ///
84    /// Opens a single SQLite connection for the session store. The cache
85    /// manager and pin manager share the same store via separate connections
86    /// (SQLite WAL mode supports concurrent readers).
87    pub fn with_preset_and_store(preset: Preset, store_path: &Path) -> Result<Self> {
88        let pipeline = CompressionPipeline::new(&preset);
89        let window_size = preset.budget.default_window_size;
90
91        // One connection per consumer. SQLite WAL mode handles concurrency.
92        let session_store = SessionStore::open_or_create(store_path)?;
93        let cache_store = SessionStore::open_or_create(store_path)?;
94        let pin_store = SessionStore::open_or_create(store_path)?;
95
96        Ok(SqzEngine {
97            preset: Arc::new(Mutex::new(preset.clone())),
98            pipeline: Arc::new(Mutex::new(pipeline)),
99            model_router: Arc::new(Mutex::new(ModelRouter::new(&preset))),
100            session_store,
101            cache_manager: CacheManager::new(cache_store, 512 * 1024 * 1024),
102            budget_tracker: BudgetTracker::new(window_size, &preset),
103            cost_calculator: CostCalculator::with_defaults(),
104            ast_parser: AstParser::new(),
105            terse_mode: TerseMode,
106            pin_manager: PinManager::new(pin_store),
107            confidence_router: ConfidenceRouter::new(),
108            _plugin_loader: PluginLoader::new(Path::new("plugins")),
109        })
110    }
111
112    /// Compress input text using the current preset.
113    ///
114    /// Two-pass pipeline:
115    /// 1. Route to compression mode based on content entropy and risk patterns.
116    /// 2. Compress using the pipeline (safe preset for Safe mode, default otherwise).
117    /// 3. Verify invariants (error lines, JSON keys, diff hunks, etc.).
118    /// 4. If verification confidence is low, fall back to safe mode and re-compress.
119    pub fn compress(&self, input: &str) -> Result<CompressedContent> {
120        let preset = self.preset.lock()
121            .map_err(|_| SqzError::Other("preset lock poisoned".into()))?;
122        let pipeline = self.pipeline.lock()
123            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
124        let ctx = crate::pipeline::SessionContext {
125            session_id: "engine".to_string(),
126        };
127
128        // Step 1: Route — check content risk before compressing
129        let mode = self.confidence_router.route(input);
130
131        // Step 2: If Safe mode, skip aggressive pipeline and go straight to safe compress
132        if mode == crate::confidence_router::CompressionMode::Safe {
133            eprintln!("[sqz] fallback: safe mode — content classified as high-risk (stack trace / migration / secret)");
134            return self.compress_safe(input, &pipeline, &ctx);
135        }
136
137        // Step 3: Compress with the configured pipeline
138        let mut result = pipeline.compress(input, &ctx, &preset)?;
139
140        // Step 4: Verify invariants
141        let verify = Verifier::verify(input, &result.data);
142        let fallback = verify.fallback_triggered;
143        result.verify = Some(verify);
144
145        // Step 5: If verifier signals low confidence, re-compress with safe settings
146        if fallback && result.data != input {
147            eprintln!("[sqz] fallback: verifier confidence {:.2} below threshold — re-compressing in safe mode",
148                result.verify.as_ref().map(|v| v.confidence).unwrap_or(0.0));
149            let safe_result = self.compress_safe(input, &pipeline, &ctx)?;
150            return Ok(safe_result);
151        }
152
153        Ok(result)
154    }
155
156    /// Compress with explicit mode override, bypassing the confidence router.
157    ///
158    /// - `CompressionMode::Safe` → safe pipeline only (ANSI strip + condense)
159    /// - `CompressionMode::Default` → standard pipeline
160    /// - `CompressionMode::Aggressive` → standard pipeline (aggressive preset TBD)
161    pub fn compress_with_mode(&self, input: &str, mode: crate::confidence_router::CompressionMode) -> Result<CompressedContent> {
162        let pipeline = self.pipeline.lock()
163            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
164        let ctx = crate::pipeline::SessionContext {
165            session_id: "engine".to_string(),
166        };
167
168        match mode {
169            crate::confidence_router::CompressionMode::Safe => {
170                self.compress_safe(input, &pipeline, &ctx)
171            }
172            _ => {
173                // Default and Aggressive: run normal pipeline + verify
174                drop(pipeline); // release lock before calling compress()
175                self.compress(input)
176            }
177        }
178    }
179
180    /// Safe-mode compression: minimal transforms only (ANSI strip + condense).
181    fn compress_safe(
182        &self,
183        input: &str,
184        pipeline: &crate::pipeline::CompressionPipeline,
185        ctx: &crate::pipeline::SessionContext,
186    ) -> Result<CompressedContent> {
187        use crate::preset::{
188            CompressionConfig, CondenseConfig, CustomTransformsConfig, BudgetConfig,
189            ModelConfig, PresetMeta, TerseModeConfig, TerseLevel, ToolSelectionConfig,
190        };
191
192        let safe_preset = Preset {
193            preset: PresetMeta {
194                name: "safe".to_string(),
195                version: "1.0".to_string(),
196                description: "Safe fallback — minimal compression".to_string(),
197            },
198            compression: CompressionConfig {
199                stages: vec!["condense".to_string()],
200                keep_fields: None,
201                strip_fields: None,
202                condense: Some(CondenseConfig { enabled: true, max_repeated_lines: 3 }),
203                git_diff_fold: None,
204                strip_nulls: None,
205                flatten: None,
206                truncate_strings: None,
207                collapse_arrays: None,
208                custom_transforms: Some(CustomTransformsConfig { enabled: false }),
209            },
210            tool_selection: ToolSelectionConfig {
211                max_tools: 5,
212                similarity_threshold: 0.7,
213                default_tools: vec![],
214            },
215            budget: BudgetConfig {
216                warning_threshold: 0.70,
217                ceiling_threshold: 0.85,
218                default_window_size: 200_000,
219                agents: Default::default(),
220            },
221            terse_mode: TerseModeConfig { enabled: false, level: TerseLevel::Moderate },
222            model: ModelConfig {
223                family: "anthropic".to_string(),
224                primary: String::new(),
225                local: String::new(),
226                complexity_threshold: 0.4,
227                pricing: None,
228            },
229        };
230
231        let mut result = pipeline.compress(input, ctx, &safe_preset)?;
232        let verify = Verifier::verify(input, &result.data);
233        result.verify = Some(verify);
234        result.provenance = Provenance {
235            label: Some("safe-fallback".to_string()),
236            ..Default::default()
237        };
238        Ok(result)
239    }
240
241    /// Compress with explicit provenance metadata attached to the result.
242    pub fn compress_with_provenance(
243        &self,
244        input: &str,
245        provenance: Provenance,
246    ) -> Result<CompressedContent> {
247        let mut result = self.compress(input)?;
248        result.provenance = provenance;
249        Ok(result)
250    }
251
252    /// Export a session to CTX format.
253    pub fn export_ctx(&self, session_id: &str) -> Result<String> {
254        let session = self.session_store.load_session(session_id.to_string())?;
255        CtxFormat::serialize(&session)
256    }
257
258    /// Import a CTX string and save as a new session.
259    pub fn import_ctx(&self, ctx: &str) -> Result<SessionId> {
260        let session = CtxFormat::deserialize(ctx)?;
261        self.session_store.save_session(&session)
262    }
263
264    /// Pin a conversation turn.
265    pub fn pin(&self, session_id: &str, turn_index: usize, reason: &str, tokens: u32) -> Result<PinEntry> {
266        self.pin_manager.pin(session_id, turn_index, reason, tokens)
267    }
268
269    /// Unpin a conversation turn.
270    pub fn unpin(&self, session_id: &str, turn_index: usize) -> Result<()> {
271        self.pin_manager.unpin(session_id, turn_index)
272    }
273
274    /// Search sessions by keyword.
275    pub fn search_sessions(&self, query: &str) -> Result<Vec<SessionSummary>> {
276        self.session_store.search(query)
277    }
278
279    /// Get usage report for an agent.
280    pub fn usage_report(&self, agent_id: &str) -> UsageReport {
281        self.budget_tracker.usage_report(agent_id.to_string())
282    }
283
284    /// Get cost summary for a session.
285    pub fn cost_summary(&self, session_id: &str) -> Result<SessionCostSummary> {
286        let session = self.session_store.load_session(session_id.to_string())?;
287        Ok(self.cost_calculator.session_summary(&session))
288    }
289
290    /// Reload the preset from a TOML string (hot-reload support).
291    pub fn reload_preset(&mut self, toml: &str) -> Result<()> {
292        let new_preset = PresetParser::parse(toml)?;
293        if let Ok(mut pipeline) = self.pipeline.lock() {
294            pipeline.reload_preset(&new_preset)?;
295        }
296        if let Ok(mut router) = self.model_router.lock() {
297            *router = ModelRouter::new(&new_preset);
298        }
299        if let Ok(mut preset) = self.preset.lock() {
300            *preset = new_preset;
301        }
302        Ok(())
303    }
304
305    /// Spawn a background thread that watches `path` for preset file changes.
306    ///
307    /// Only the preset, pipeline, and model_router are shared with the watcher
308    /// thread (via `Arc<Mutex<>>`). All other engine state stays on the main thread.
309    pub fn watch_preset_file(&self, path: &Path) -> Result<notify::RecommendedWatcher> {
310        use notify::{Event, EventKind, RecursiveMode, Watcher};
311
312        let preset_arc = Arc::clone(&self.preset);
313        let pipeline_arc = Arc::clone(&self.pipeline);
314        let router_arc = Arc::clone(&self.model_router);
315        let watched_path = path.to_owned();
316
317        let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
318            if let Ok(event) = res {
319                if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
320                    match std::fs::read_to_string(&watched_path) {
321                        Ok(toml_str) => match PresetParser::parse(&toml_str) {
322                            Ok(new_preset) => {
323                                if let Ok(mut p) = pipeline_arc.lock() {
324                                    let _ = p.reload_preset(&new_preset);
325                                }
326                                if let Ok(mut r) = router_arc.lock() {
327                                    *r = ModelRouter::new(&new_preset);
328                                }
329                                if let Ok(mut pr) = preset_arc.lock() {
330                                    *pr = new_preset;
331                                }
332                            }
333                            Err(e) => eprintln!("[sqz] invalid preset: {e}"),
334                        },
335                        Err(e) => eprintln!("[sqz] preset read error: {e}"),
336                    }
337                }
338            }
339        })
340        .map_err(|e| SqzError::Other(format!("watcher error: {e}")))?;
341
342        watcher
343            .watch(path, RecursiveMode::NonRecursive)
344            .map_err(|e| SqzError::Other(format!("watch error: {e}")))?;
345
346        Ok(watcher)
347    }
348
349    /// Access the underlying `SessionStore`.
350    pub fn session_store(&self) -> &SessionStore {
351        &self.session_store
352    }
353
354    /// Access the `AstParser`.
355    pub fn ast_parser(&self) -> &AstParser {
356        &self.ast_parser
357    }
358
359    /// Access the `TerseMode` helper.
360    pub fn terse_mode(&self) -> &TerseMode {
361        &self.terse_mode
362    }
363
364    /// Reorder context sections using the LITM positioner to mitigate
365    /// the "Lost In The Middle" attention bias in long-context models.
366    ///
367    /// Places highest-priority sections at the beginning and end of the
368    /// context window, lowest-priority in the middle.
369    pub fn reorder_context(
370        &self,
371        sections: &mut Vec<crate::litm_positioner::ContextSection>,
372        strategy: crate::litm_positioner::LitmStrategy,
373    ) {
374        let positioner = crate::litm_positioner::LitmPositioner::new(strategy);
375        positioner.reorder(sections);
376    }
377
378    /// Route content to the appropriate compression mode based on entropy
379    /// and risk pattern analysis.
380    pub fn route_compression_mode(&self, content: &str) -> crate::confidence_router::CompressionMode {
381        self.confidence_router.route(content)
382    }
383}
384
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
390    use chrono::Utc;
391    use std::path::PathBuf;
392
393    fn make_session(id: &str) -> SessionState {
394        let now = Utc::now();
395        SessionState {
396            id: id.to_string(),
397            project_dir: PathBuf::from("/tmp/test"),
398            conversation: vec![],
399            corrections: CorrectionLog::default(),
400            pins: vec![],
401            learnings: vec![],
402            compressed_summary: "test session".to_string(),
403            budget: BudgetState {
404                window_size: 200_000,
405                consumed: 0,
406                pinned: 0,
407                model_family: ModelFamily::AnthropicClaude,
408            },
409            tool_usage: vec![],
410            created_at: now,
411            updated_at: now,
412        }
413    }
414
415    #[test]
416    fn test_engine_new() {
417        let engine = SqzEngine::new();
418        assert!(engine.is_ok(), "SqzEngine::new() should succeed");
419    }
420
421    #[test]
422    fn test_compress_plain_text() {
423        let engine = SqzEngine::new().unwrap();
424        let result = engine.compress("hello world");
425        assert!(result.is_ok());
426        assert_eq!(result.unwrap().data, "hello world");
427    }
428
429    #[test]
430    fn test_compress_json_applies_toon() {
431        let engine = SqzEngine::new().unwrap();
432        let result = engine.compress(r#"{"name":"Alice","age":30}"#).unwrap();
433        assert!(result.data.starts_with("TOON:"), "JSON should be TOON-encoded");
434    }
435
436    #[test]
437    fn test_export_import_ctx_round_trip() {
438        let dir = tempfile::tempdir().unwrap();
439        let store_path = dir.path().join("store.db");
440        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
441
442        let session = make_session("sess-rt");
443        engine.session_store().save_session(&session).unwrap();
444
445        let ctx = engine.export_ctx("sess-rt").unwrap();
446        let imported_id = engine.import_ctx(&ctx).unwrap();
447        assert_eq!(imported_id, "sess-rt");
448    }
449
450    #[test]
451    fn test_search_sessions() {
452        let dir = tempfile::tempdir().unwrap();
453        let store_path = dir.path().join("store.db");
454        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
455
456        let mut session = make_session("sess-search");
457        session.compressed_summary = "authentication refactor".to_string();
458        engine.session_store().save_session(&session).unwrap();
459
460        let results = engine.search_sessions("authentication").unwrap();
461        assert_eq!(results.len(), 1);
462        assert_eq!(results[0].id, "sess-search");
463    }
464
465    #[test]
466    fn test_usage_report_starts_at_zero() {
467        let engine = SqzEngine::new().unwrap();
468        let report = engine.usage_report("default");
469        assert_eq!(report.consumed, 0);
470        assert_eq!(report.available, report.allocated);
471    }
472
473    #[test]
474    fn test_cost_summary() {
475        let dir = tempfile::tempdir().unwrap();
476        let store_path = dir.path().join("store.db");
477        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
478
479        let session = make_session("sess-cost");
480        engine.session_store().save_session(&session).unwrap();
481
482        let summary = engine.cost_summary("sess-cost").unwrap();
483        assert_eq!(summary.total_tokens, 0);
484        assert!((summary.total_usd - 0.0).abs() < f64::EPSILON);
485    }
486
487    #[test]
488    fn test_reload_preset_updates_state() {
489        let mut engine = SqzEngine::new().unwrap();
490        let toml = r#"
491[preset]
492name = "reloaded"
493version = "2.0"
494
495[compression]
496stages = []
497
498[tool_selection]
499max_tools = 5
500similarity_threshold = 0.7
501
502[budget]
503warning_threshold = 0.70
504ceiling_threshold = 0.85
505default_window_size = 200000
506
507[terse_mode]
508enabled = false
509level = "moderate"
510
511[model]
512family = "anthropic"
513primary = "claude-sonnet-4-20250514"
514complexity_threshold = 0.4
515"#;
516        assert!(engine.reload_preset(toml).is_ok());
517        // Verify the preset was actually updated
518        let preset = engine.preset.lock().unwrap();
519        assert_eq!(preset.preset.name, "reloaded");
520    }
521
522    #[test]
523    fn test_reload_invalid_preset_returns_error() {
524        let mut engine = SqzEngine::new().unwrap();
525        let result = engine.reload_preset("not valid toml [[[");
526        assert!(result.is_err(), "invalid TOML should return error");
527    }
528
529    #[test]
530    fn test_export_nonexistent_session_returns_error() {
531        let engine = SqzEngine::new().unwrap();
532        let result = engine.export_ctx("does-not-exist");
533        assert!(result.is_err());
534    }
535
536    #[test]
537    fn test_import_invalid_ctx_returns_error() {
538        let engine = SqzEngine::new().unwrap();
539        let result = engine.import_ctx("not valid json {{{");
540        assert!(result.is_err());
541    }
542}