Skip to main content

sqz_engine/
engine.rs

1use std::path::Path;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use crate::ast_parser::AstParser;
6use crate::budget_tracker::{BudgetTracker, UsageReport};
7use crate::cache_manager::CacheManager;
8use crate::confidence_router::ConfidenceRouter;
9use crate::cost_calculator::{CostCalculator, SessionCostSummary};
10use crate::ctx_format::CtxFormat;
11use crate::error::{Result, SqzError};
12use crate::model_router::ModelRouter;
13use crate::pin_manager::PinManager;
14use crate::pipeline::CompressionPipeline;
15use crate::plugin_api::PluginLoader;
16use crate::preset::{Preset, PresetParser};
17use crate::session_store::{SessionStore, SessionSummary};
18use crate::terse_mode::TerseMode;
19use crate::types::{CompressedContent, PinEntry, Provenance, SessionId};
20use crate::verifier::Verifier;
21
22/// Top-level facade that wires all sqz_engine modules together.
23///
24/// # Concurrency design
25///
26/// `SqzEngine` is designed for single-threaded use on the main thread.
27/// The only cross-thread sharing happens during preset hot-reload: the
28/// file-watcher callback runs on a background thread and needs to update
29/// the preset, pipeline, and model router. These three fields are wrapped
30/// in `Arc<Mutex<>>` specifically for that purpose. All other fields are
31/// owned directly — no unnecessary synchronization.
32pub struct SqzEngine {
33    // --- Hot-reloadable state (shared with file-watcher thread) ---
34    preset: Arc<Mutex<Preset>>,
35    pipeline: Arc<Mutex<CompressionPipeline>>,
36    model_router: Arc<Mutex<ModelRouter>>,
37
38    // --- Single-owner state (no cross-thread sharing needed) ---
39    session_store: SessionStore,
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.
62    ///
63    /// `SQZ_DB_PATH` overrides everything — set it to keep the database
64    /// (stats, dedup cache, sessions) per-project instead of global.
65    /// Relative values resolve against the process working directory, so
66    /// prefer absolute paths when hooks may run from subdirectories.
67    /// Otherwise `~/.sqz/sessions.db`, falling back to a temp-file path
68    /// if the home dir is unavailable.
69    pub fn default_store_path() -> std::path::PathBuf {
70        if let Some(custom) = Self::store_path_from_env(std::env::var("SQZ_DB_PATH").ok()) {
71            return custom;
72        }
73        if let Some(home) = dirs_next::home_dir() {
74            let sqz_dir = home.join(".sqz");
75            if std::fs::create_dir_all(&sqz_dir).is_ok() {
76                // Harden permissions on Unix: ~/.sqz/ contains session data
77                // and cached content that may include sensitive output.
78                #[cfg(unix)]
79                {
80                    use std::os::unix::fs::PermissionsExt;
81                    let _ = std::fs::set_permissions(
82                        &sqz_dir,
83                        std::fs::Permissions::from_mode(0o700),
84                    );
85                }
86                return sqz_dir.join("sessions.db");
87            }
88        }
89        // Fallback: temp dir with unique name
90        let dir = std::env::temp_dir();
91        dir.join(format!(
92            "sqz_session_{}_{}.db",
93            std::process::id(),
94            std::time::SystemTime::now()
95                .duration_since(std::time::UNIX_EPOCH)
96                .map(|d| d.as_nanos())
97                .unwrap_or(0)
98        ))
99    }
100
101    /// Turn an `SQZ_DB_PATH` value into a usable store path, creating the
102    /// parent directory (0700 on Unix, matching `~/.sqz`). Empty or
103    /// whitespace-only values are treated as unset.
104    fn store_path_from_env(value: Option<String>) -> Option<std::path::PathBuf> {
105        let raw = value?;
106        let trimmed = raw.trim();
107        if trimmed.is_empty() {
108            return None;
109        }
110        let path = std::path::PathBuf::from(trimmed);
111        if let Some(parent) = path.parent() {
112            if !parent.as_os_str().is_empty() && !parent.exists() {
113                // Harden only directories we create ourselves — never
114                // chmod a pre-existing directory the user pointed into.
115                if std::fs::create_dir_all(parent).is_ok() {
116                    #[cfg(unix)]
117                    {
118                        use std::os::unix::fs::PermissionsExt;
119                        let _ = std::fs::set_permissions(
120                            parent,
121                            std::fs::Permissions::from_mode(0o700),
122                        );
123                    }
124                }
125            }
126        }
127        Some(path)
128    }
129
130    /// Whether informational stderr lines should be suppressed.
131    ///
132    /// `SQZ_QUIET=1` silences the per-compression progress lines ("N/M
133    /// tokens", "dedup hit", "fallback: verifier confidence …"). Errors
134    /// still print. Used by `sqz discover --replay`, which compresses
135    /// thousands of outputs in one run, and available to users whose
136    /// hook setup surfaces stderr where it isn't wanted.
137    pub fn quiet_stderr() -> bool {
138        match std::env::var("SQZ_QUIET") {
139            Ok(v) => !v.is_empty() && v != "0",
140            Err(_) => false,
141        }
142    }
143
144    /// How long a dedup ref stays valid after the original was served.
145    ///
146    /// `SQZ_REF_TTL_SECS` overrides the 30-minute default. Clients that
147    /// compact context aggressively (or have no PreCompact hook) can set
148    /// this lower so refs stop pointing at content the model has already
149    /// dropped. Unparseable or empty values fall back to the default.
150    fn ref_ttl_from_env(value: Option<String>) -> Duration {
151        const DEFAULT: Duration = Duration::from_secs(30 * 60);
152        match value.as_deref().map(str::trim) {
153            Some(raw) if !raw.is_empty() => raw
154                .parse::<u64>()
155                .map(Duration::from_secs)
156                .unwrap_or(DEFAULT),
157            _ => DEFAULT,
158        }
159    }
160
161    /// Create with a custom preset and a file-backed session store.
162    ///
163    /// Opens a single SQLite connection for the session store. The cache
164    /// manager and pin manager share the same store via separate connections
165    /// (SQLite WAL mode supports concurrent readers).
166    pub fn with_preset_and_store(preset: Preset, store_path: &Path) -> Result<Self> {
167        let pipeline = CompressionPipeline::new(&preset);
168        let window_size = preset.budget.default_window_size;
169
170        // One connection per consumer. SQLite WAL mode handles concurrency.
171        let session_store = SessionStore::open_or_create(store_path)?;
172        let cache_store = SessionStore::open_or_create(store_path)?;
173        let pin_store = SessionStore::open_or_create(store_path)?;
174
175        Ok(SqzEngine {
176            preset: Arc::new(Mutex::new(preset.clone())),
177            pipeline: Arc::new(Mutex::new(pipeline)),
178            model_router: Arc::new(Mutex::new(ModelRouter::new(&preset))),
179            session_store,
180            cache_manager: CacheManager::with_ref_age_duration(
181                cache_store,
182                512 * 1024 * 1024,
183                Self::ref_ttl_from_env(std::env::var("SQZ_REF_TTL_SECS").ok()),
184            ),
185            budget_tracker: BudgetTracker::new(window_size, &preset),
186            cost_calculator: CostCalculator::with_defaults(),
187            ast_parser: AstParser::new(),
188            terse_mode: TerseMode,
189            pin_manager: PinManager::new(pin_store),
190            confidence_router: ConfidenceRouter::new(),
191            _plugin_loader: PluginLoader::new(Path::new("plugins")),
192        })
193    }
194
195    /// Compress input text using the current preset.
196    ///
197    /// Two-pass pipeline:
198    /// 1. Route to compression mode based on content entropy and risk patterns.
199    /// 2. Compress using the pipeline (safe preset for Safe mode, default otherwise).
200    /// 3. Verify invariants (error lines, JSON keys, diff hunks, etc.).
201    /// 4. If verification confidence is low, fall back to safe mode and re-compress.
202    pub fn compress(&self, input: &str) -> Result<CompressedContent> {
203        let preset = self.preset.lock()
204            .map_err(|_| SqzError::Other("preset lock poisoned".into()))?;
205        let pipeline = self.pipeline.lock()
206            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
207        let ctx = crate::pipeline::SessionContext {
208            session_id: "engine".to_string(),
209        };
210
211        // Step 1: Route — check content risk before compressing
212        let mode = self.confidence_router.route(input);
213
214        // Step 2: If Safe mode, skip aggressive pipeline and go straight to safe compress
215        if mode == crate::confidence_router::CompressionMode::Safe {
216            // Silent by default: in the hook path every stderr line lands in
217            // the agent's context, and a passthrough has nothing to report.
218            if std::env::var_os("SQZ_VERBOSE").is_some() {
219                eprintln!("[sqz] safe mode: high-risk content passed through unchanged");
220            }
221            return self.compress_safe(input, &pipeline, &ctx, true);
222        }
223
224        // Step 3: Compress with the configured pipeline
225        let mut result = pipeline.compress_uncounted(input, &ctx, &preset)?;
226
227        // Step 4: Verify invariants
228        let verify = Verifier::verify(input, &result.data);
229        let fallback = verify.fallback_triggered;
230        result.verify = Some(verify);
231
232        // Step 5: If verifier signals low confidence, re-compress with safe settings
233        if fallback && result.data != input {
234            if !Self::quiet_stderr() {
235                eprintln!("[sqz] fallback: verifier confidence {:.2} below threshold — re-compressing in safe mode",
236                    result.verify.as_ref().map(|v| v.confidence).unwrap_or(0.0));
237            }
238            let safe_result = self.compress_safe(input, &pipeline, &ctx, false)?;
239            return Ok(safe_result);
240        }
241
242        pipeline.count_output(&mut result, &preset);
243        Ok(result)
244    }
245
246    /// Compress with dedup cache lookup.
247    ///
248    /// Unlike [`compress`](Self::compress), this method consults the persistent cache
249    /// (`~/.sqz/sessions.db`). On a hit with a fresh ref, it returns a
250    /// 13-token `§ref:HASH§` token instead of re-compressing. On a
251    /// near-duplicate, it returns a compact delta. On a cache miss, it
252    /// runs the full pipeline, stores the result, and returns it.
253    /// Content [`never_store`](Self::never_store) flags skips the cache.
254    ///
255    /// This is the path the MCP `sqz_read_file` / `sqz_grep` /
256    /// `sqz_list_dir` tools use — repeat reads in the same session
257    /// (and across sessions if the DB survives) collapse to 13 tokens.
258    /// Reported on issue #12: the MCP file tools bypassed the cache so
259    /// dedup never fired, and users were getting 30%-range pipeline
260    /// compression instead of the 92% dedup path advertised in the
261    /// README.
262    pub fn compress_with_cache(&self, input: &str) -> Result<crate::cache_manager::CacheResult> {
263        // The `path` argument is informational only — cache keys are
264        // SHA-256 of the content bytes, not the path. Pass an empty
265        // path so we don't partition the cache by accidental file
266        // location differences (e.g. `./src/main.rs` vs
267        // `/abs/path/to/src/main.rs`). Misses go through `compress`, so
268        // the verifier's safe-mode fallback applies here too.
269        if self.never_store(input) {
270            return Ok(crate::cache_manager::CacheResult::Fresh { output: self.compress(input)? });
271        }
272        self.cache_manager.get_or_compress_with(
273            std::path::Path::new(""),
274            input.as_bytes(),
275            &|text| self.compress(text),
276        )
277    }
278
279    /// Lossless variant of [`compress_with_cache`](Self::compress_with_cache)
280    /// for the file-read MCP tools
281    /// (`sqz_read_file` / `sqz_grep` / `sqz_list_dir`): on a cache miss the
282    /// content is returned faithfully — no entropy truncation, pruning, or
283    /// re-encoding. The general `compress` tool keeps using
284    /// [`compress_with_cache`](Self::compress_with_cache) for aggressive
285    /// compression. See issue #32.
286    pub fn compress_with_cache_lossless(&self, input: &str) -> Result<crate::cache_manager::CacheResult> {
287        let pipeline = self.pipeline.lock()
288            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
289        if self.never_store(input) {
290            let ctx = crate::pipeline::SessionContext { session_id: "cache".to_string() };
291            let output = pipeline.compress_lossless(input, &ctx, &Preset::default())?;
292            return Ok(crate::cache_manager::CacheResult::Fresh { output });
293        }
294        self.cache_manager.get_or_compress_lossless(
295            std::path::Path::new(""),
296            input.as_bytes(),
297            &pipeline,
298        )
299    }
300
301    /// What [`Self::compress_with_cache_lossless`] returns on a miss, without the cache.
302    pub fn compress_lossless(&self, input: &str) -> Result<CompressedContent> {
303        let pipeline = self.pipeline.lock()
304            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
305        let ctx = crate::pipeline::SessionContext { session_id: "cache".to_string() };
306        pipeline.compress_lossless(input, &ctx, &Preset::default())
307    }
308
309    /// What `sqz-mcp proxy` does to one text result from an upstream MCP
310    /// server. `None` keeps the original: too small to pay for the
311    /// markers, high-risk content (credentials, stack traces,
312    /// migrations), or a saving under the net-win gate. `sqz discover
313    /// --replay` uses it too, so its MCP estimate is what the proxy does.
314    pub fn compress_tool_result(&self, text: &str, use_cache: bool) -> Option<ToolResultCompression> {
315        use crate::cache_manager::CacheResult;
316        let tokens_original = chars_over_four(text);
317        if tokens_original < TOOL_RESULT_MIN_SAVING * 2 {
318            return None;
319        }
320        if self.route_compression_mode(text) == crate::confidence_router::CompressionMode::Safe {
321            return None;
322        }
323        let use_cache = use_cache && !self.never_store(text);
324
325        let result = if use_cache {
326            self.compress_with_cache(text).ok()
327        } else {
328            self.compress(text).ok().map(|output| CacheResult::Fresh { output })
329        }?;
330
331        let (out, tokens_compressed, repeat) = match result {
332            CacheResult::Dedup { inline_ref, token_cost } => (inline_ref, token_cost, true),
333            CacheResult::Delta { delta_text, token_cost, .. } => (delta_text, token_cost, true),
334            CacheResult::Fresh { output } => {
335                // First pass is lossy, so point at the cached original.
336                let mut data = output.data;
337                if use_cache {
338                    let hash = crate::cache_manager::CacheManager::sha256_hex(text.as_bytes());
339                    data.push_str(&format!(
340                        "\n[full output: call sqz_expand with ref \"{}\"]",
341                        &hash[..16]
342                    ));
343                }
344                let t = chars_over_four(&data);
345                (data, t, false)
346            }
347        };
348        if tokens_original.saturating_sub(tokens_compressed) < TOOL_RESULT_MIN_SAVING {
349            return None;
350        }
351        Some(ToolResultCompression {
352            text: out,
353            tokens_original,
354            tokens_compressed,
355            repeat,
356        })
357    }
358
359    /// Defensive compression: any input in, `CompressedContent` out, guaranteed.
360    ///
361    /// Unlike `compress()` which returns `Result`, this method never returns
362    /// an error. On any internal failure it returns the original input
363    /// unchanged with a 1.0 compression ratio. This makes it safe to call
364    /// from contexts where error handling is impractical (e.g. shell hooks,
365    /// browser extension bridges).
366    pub fn compress_or_passthrough(&self, input: &str) -> CompressedContent {
367        match self.compress(input) {
368            Ok(result) => result,
369            Err(_) => {
370                let tokens = (input.len() as u32 + 3) / 4;
371                CompressedContent {
372                    data: input.to_string(),
373                    tokens_compressed: tokens,
374                    tokens_original: tokens,
375                    stages_applied: vec![],
376                    compression_ratio: 1.0,
377                    provenance: crate::types::Provenance::default(),
378                    verify: None,
379                }
380            }
381        }
382    }
383
384    /// Compress with explicit mode override, bypassing the confidence router.
385    ///
386    /// - `CompressionMode::Safe` → safe pipeline only (ANSI strip + condense)
387    /// - `CompressionMode::Default` → standard pipeline
388    /// - `CompressionMode::Aggressive` → standard pipeline (aggressive preset TBD)
389    pub fn compress_with_mode(&self, input: &str, mode: crate::confidence_router::CompressionMode) -> Result<CompressedContent> {
390        let pipeline = self.pipeline.lock()
391            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
392        let ctx = crate::pipeline::SessionContext {
393            session_id: "engine".to_string(),
394        };
395
396        match mode {
397            crate::confidence_router::CompressionMode::Safe => {
398                let lossless = self.confidence_router.route(input)
399                    == crate::confidence_router::CompressionMode::Safe;
400                self.compress_safe(input, &pipeline, &ctx, lossless)
401            }
402            _ => {
403                // Default and Aggressive: run normal pipeline + verify
404                drop(pipeline); // release lock before calling compress()
405                self.compress(input)
406            }
407        }
408    }
409
410    /// Safe-mode compression: minimal transforms only (ANSI strip + condense).
411    fn compress_safe(
412        &self,
413        input: &str,
414        pipeline: &crate::pipeline::CompressionPipeline,
415        ctx: &crate::pipeline::SessionContext,
416        lossless: bool,
417    ) -> Result<CompressedContent> {
418        use crate::preset::{
419            CompressionConfig, CondenseConfig, CustomTransformsConfig, BudgetConfig,
420            ModelConfig, PresetMeta, TerseModeConfig, TerseLevel, ToolSelectionConfig,
421        };
422
423        let safe_preset = Preset {
424            preset: PresetMeta {
425                name: "safe".to_string(),
426                version: "1.0".to_string(),
427                description: "Safe fallback — minimal compression".to_string(),
428            },
429            compression: CompressionConfig {
430                stages: vec!["condense".to_string()],
431                keep_fields: None,
432                strip_fields: None,
433                condense: Some(CondenseConfig { enabled: true, max_repeated_lines: 3 }),
434                git_diff_fold: None,
435                strip_nulls: None,
436                flatten: None,
437                truncate_strings: None,
438                collapse_arrays: None,
439                custom_transforms: Some(CustomTransformsConfig { enabled: false }),
440            },
441            tool_selection: ToolSelectionConfig {
442                max_tools: 5,
443                similarity_threshold: 0.7,
444                default_tools: vec![],
445            },
446            budget: BudgetConfig {
447                warning_threshold: 0.70,
448                ceiling_threshold: 0.85,
449                default_window_size: 200_000,
450                agents: Default::default(),
451            },
452            terse_mode: TerseModeConfig { enabled: false, level: TerseLevel::Moderate },
453            model: ModelConfig {
454                family: "anthropic".to_string(),
455                primary: String::new(),
456                local: String::new(),
457                complexity_threshold: 0.4,
458                pricing: None,
459            },
460        };
461
462        let mut result = if lossless {
463            pipeline.compress_lossless(input, ctx, &safe_preset)?
464        } else {
465            pipeline.compress(input, ctx, &safe_preset)?
466        };
467        let verify = Verifier::verify(input, &result.data);
468        result.verify = Some(verify);
469        result.provenance = Provenance {
470            label: Some("safe-fallback".to_string()),
471            ..Default::default()
472        };
473        Ok(result)
474    }
475
476    /// Compress with explicit provenance metadata attached to the result.
477    pub fn compress_with_provenance(
478        &self,
479        input: &str,
480        provenance: Provenance,
481    ) -> Result<CompressedContent> {
482        let mut result = self.compress(input)?;
483        result.provenance = provenance;
484        Ok(result)
485    }
486
487    /// Export a session to CTX format.
488    pub fn export_ctx(&self, session_id: &str) -> Result<String> {
489        let session = self.session_store.load_session(session_id.to_string())?;
490        CtxFormat::serialize(&session)
491    }
492
493    /// Import a CTX string and save as a new session.
494    pub fn import_ctx(&self, ctx: &str) -> Result<SessionId> {
495        let session = CtxFormat::deserialize(ctx)?;
496        self.session_store.save_session(&session)
497    }
498
499    /// Pin a conversation turn.
500    pub fn pin(&self, session_id: &str, turn_index: usize, reason: &str, tokens: u32) -> Result<PinEntry> {
501        self.pin_manager.pin(session_id, turn_index, reason, tokens)
502    }
503
504    /// Unpin a conversation turn.
505    pub fn unpin(&self, session_id: &str, turn_index: usize) -> Result<()> {
506        self.pin_manager.unpin(session_id, turn_index)
507    }
508
509    /// Search sessions by keyword.
510    pub fn search_sessions(&self, query: &str) -> Result<Vec<SessionSummary>> {
511        self.session_store.search(query)
512    }
513
514    /// Get usage report for an agent.
515    pub fn usage_report(&self, agent_id: &str) -> UsageReport {
516        self.budget_tracker.usage_report(agent_id.to_string())
517    }
518
519    /// Get cost summary for a session.
520    pub fn cost_summary(&self, session_id: &str) -> Result<SessionCostSummary> {
521        let session = self.session_store.load_session(session_id.to_string())?;
522        Ok(self.cost_calculator.session_summary(&session))
523    }
524
525    /// Reload the preset from a TOML string (hot-reload support).
526    pub fn reload_preset(&mut self, toml: &str) -> Result<()> {
527        let new_preset = PresetParser::parse(toml)?;
528        if let Ok(mut pipeline) = self.pipeline.lock() {
529            pipeline.reload_preset(&new_preset)?;
530        }
531        if let Ok(mut router) = self.model_router.lock() {
532            *router = ModelRouter::new(&new_preset);
533        }
534        if let Ok(mut preset) = self.preset.lock() {
535            *preset = new_preset;
536        }
537        Ok(())
538    }
539
540    /// Spawn a background thread that watches `path` for preset file changes.
541    ///
542    /// Only the preset, pipeline, and model_router are shared with the watcher
543    /// thread (via `Arc<Mutex<>>`). All other engine state stays on the main thread.
544    pub fn watch_preset_file(&self, path: &Path) -> Result<notify::RecommendedWatcher> {
545        use notify::{Event, EventKind, RecursiveMode, Watcher};
546
547        let preset_arc = Arc::clone(&self.preset);
548        let pipeline_arc = Arc::clone(&self.pipeline);
549        let router_arc = Arc::clone(&self.model_router);
550        let watched_path = path.to_owned();
551
552        let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
553            if let Ok(event) = res {
554                if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
555                    match std::fs::read_to_string(&watched_path) {
556                        Ok(toml_str) => match PresetParser::parse(&toml_str) {
557                            Ok(new_preset) => {
558                                if let Ok(mut p) = pipeline_arc.lock() {
559                                    let _ = p.reload_preset(&new_preset);
560                                }
561                                if let Ok(mut r) = router_arc.lock() {
562                                    *r = ModelRouter::new(&new_preset);
563                                }
564                                if let Ok(mut pr) = preset_arc.lock() {
565                                    *pr = new_preset;
566                                }
567                            }
568                            Err(e) => eprintln!("[sqz] invalid preset: {e}"),
569                        },
570                        Err(e) => eprintln!("[sqz] preset read error: {e}"),
571                    }
572                }
573            }
574        })
575        .map_err(|e| SqzError::Other(format!("watcher error: {e}")))?;
576
577        watcher
578            .watch(path, RecursiveMode::NonRecursive)
579            .map_err(|e| SqzError::Other(format!("watch error: {e}")))?;
580
581        Ok(watcher)
582    }
583
584    /// Access the underlying `SessionStore`.
585    pub fn session_store(&self) -> &SessionStore {
586        &self.session_store
587    }
588
589    /// Access the `CacheManager` for persistent dedup.
590    pub fn cache_manager(&self) -> &CacheManager {
591        &self.cache_manager
592    }
593
594    /// Access the `AstParser`.
595    pub fn ast_parser(&self) -> &AstParser {
596        &self.ast_parser
597    }
598
599    /// Access the `TerseMode` helper.
600    pub fn terse_mode(&self) -> &TerseMode {
601        &self.terse_mode
602    }
603
604    /// Reorder context sections using the LITM positioner to mitigate
605    /// the "Lost In The Middle" attention bias in long-context models.
606    ///
607    /// Places highest-priority sections at the beginning and end of the
608    /// context window, lowest-priority in the middle.
609    pub fn reorder_context(
610        &self,
611        sections: &mut Vec<crate::litm_positioner::ContextSection>,
612        strategy: crate::litm_positioner::LitmStrategy,
613    ) {
614        let positioner = crate::litm_positioner::LitmPositioner::new(strategy);
615        positioner.reorder(sections);
616    }
617
618    /// Route content to the appropriate compression mode based on entropy
619    /// and risk pattern analysis.
620    pub fn route_compression_mode(&self, content: &str) -> crate::confidence_router::CompressionMode {
621        self.confidence_router.route(content)
622    }
623
624    /// Whether content must stay out of the persistent cache.
625    pub fn never_store(&self, content: &str) -> bool {
626        self.confidence_router.never_store(content)
627    }
628
629    /// Count tokens the way `compress` counts `tokens_original`.
630    pub fn count_tokens(&self, text: &str) -> u32 {
631        let preset = self.preset.lock().unwrap();
632        self.pipeline.lock().unwrap().count_tokens(text, &preset)
633    }
634
635    /// Clear the lock poison a caught panic left, so the engine stays usable.
636    pub fn clear_poison(&self) {
637        self.preset.clear_poison();
638        self.pipeline.clear_poison();
639        self.model_router.clear_poison();
640    }
641}
642
643/// Smallest saving, in tokens, worth replacing an MCP tool result for: the
644/// markers and the agent's attention cost about this much.
645pub const TOOL_RESULT_MIN_SAVING: u32 = 16;
646
647/// A compressed MCP tool result from [`SqzEngine::compress_tool_result`].
648/// Token counts are chars/4 estimates, the counter the net-win gate uses.
649#[derive(Debug, Clone, PartialEq, Eq)]
650pub struct ToolResultCompression {
651    pub text: String,
652    pub tokens_original: u32,
653    pub tokens_compressed: u32,
654    /// A ref or delta against content already sent: lossless. Otherwise a
655    /// first-pass compression, which is lossy.
656    pub repeat: bool,
657}
658
659fn chars_over_four(text: &str) -> u32 {
660    ((text.len() as f64) / 4.0).ceil() as u32
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
667    use chrono::Utc;
668    use std::path::PathBuf;
669
670    fn make_session(id: &str) -> SessionState {
671        let now = Utc::now();
672        SessionState {
673            id: id.to_string(),
674            project_dir: PathBuf::from("/tmp/test"),
675            conversation: vec![],
676            corrections: CorrectionLog::default(),
677            pins: vec![],
678            learnings: vec![],
679            compressed_summary: "test session".to_string(),
680            budget: BudgetState {
681                window_size: 200_000,
682                consumed: 0,
683                pinned: 0,
684                model_family: ModelFamily::AnthropicClaude,
685            },
686            tool_usage: vec![],
687            created_at: now,
688            updated_at: now,
689        }
690    }
691
692    #[test]
693    fn test_engine_new() {
694        let dir = tempfile::tempdir().unwrap();
695        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db"));
696        assert!(engine.is_ok(), "engine with the default preset should start");
697    }
698
699    #[test]
700    fn test_compress_or_passthrough_returns_result_on_valid_input() {
701        let dir = tempfile::tempdir().unwrap();
702        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
703        let result = engine.compress_or_passthrough("hello world");
704        assert_eq!(result.data, "hello world");
705        assert!(result.tokens_original > 0);
706    }
707
708    #[test]
709    fn test_compress_or_passthrough_never_panics_on_empty() {
710        let dir = tempfile::tempdir().unwrap();
711        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
712        let result = engine.compress_or_passthrough("");
713        assert_eq!(result.data, "");
714        assert_eq!(result.compression_ratio, 1.0);
715    }
716
717    #[test]
718    fn test_compress_or_passthrough_handles_json() {
719        let dir = tempfile::tempdir().unwrap();
720        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
721        let result = engine.compress_or_passthrough(r#"{"key":"value"}"#);
722        // Should compress successfully — data may be TOON-encoded
723        assert!(!result.data.is_empty());
724    }
725
726    #[test]
727    fn test_compress_or_passthrough_handles_binary_garbage() {
728        let dir = tempfile::tempdir().unwrap();
729        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
730        // Feed it something weird — should never panic, always return something
731        let garbage = "\x00\x01\x02\x7f invalid control chars \t\n\r";
732        let result = engine.compress_or_passthrough(garbage);
733        assert!(!result.data.is_empty());
734    }
735
736    #[test]
737    fn test_compress_plain_text() {
738        let dir = tempfile::tempdir().unwrap();
739        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
740        let result = engine.compress("hello world");
741        assert!(result.is_ok());
742        assert_eq!(result.unwrap().data, "hello world");
743    }
744
745    #[test]
746    fn test_compress_json_applies_toon() {
747        let dir = tempfile::tempdir().unwrap();
748        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
749        let result = engine.compress(r#"{"name":"Alice","age":30}"#).unwrap();
750        assert!(result.data.starts_with("TOON:"), "JSON should be TOON-encoded");
751    }
752
753    #[test]
754    fn compress_tool_result_gates_like_the_proxy() {
755        let dir = tempfile::tempdir().unwrap();
756        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
757
758        assert_eq!(engine.compress_tool_result("ok, 3 rows updated", true), None, "too small to pay off");
759
760        let rows: String = (0..80)
761            .map(|i| format!("issue row {i} status open priority medium assignee nobody\n"))
762            .collect();
763        let first = engine.compress_tool_result(&rows, true).expect("compresses");
764        assert!(first.tokens_compressed + TOOL_RESULT_MIN_SAVING <= first.tokens_original);
765        assert!(!first.text.starts_with("§ref:") && !first.repeat);
766        // A lossy first pass always says how to get the original back.
767        let hash = CacheManager::sha256_hex(rows.as_bytes());
768        assert!(first.text.ends_with(&format!("[full output: call sqz_expand with ref \"{}\"]", &hash[..16])), "{}", first.text);
769        let second = engine.compress_tool_result(&rows, true).expect("repeat");
770        assert!(second.text.starts_with("§ref:") && second.repeat, "{}", second.text);
771        let uncached = engine.compress_tool_result(&rows, false).expect("no cache");
772        assert!(!uncached.text.starts_with("§ref:") && !uncached.text.contains("sqz_expand"));
773    }
774
775    #[test]
776    fn clear_poison_after_a_caught_panic() {
777        let dir = tempfile::tempdir().unwrap();
778        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
779        let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
780            let _preset = engine.preset.lock().unwrap();
781            let _pipeline = engine.pipeline.lock().unwrap();
782            panic!("a bug while compressing");
783        }));
784        assert!(caught.is_err());
785        assert!(engine.compress("hello world").is_err());
786
787        engine.clear_poison();
788        assert_eq!(engine.compress("hello world").unwrap().data, "hello world");
789        assert!(engine.count_tokens("hello world") > 0);
790    }
791
792    fn high_risk_inputs() -> Vec<(&'static str, String)> {
793        let frames: String = (0..40)
794            .map(|i| format!("  {i:>2}: myapp::scheduler::step_{i}\n             at ./src/scheduler/mod_{}.rs:{}:5\n", i % 7, 40 + 7 * i))
795            .collect();
796        let tables: String = ["customers", "orders", "refunds", "payments", "shipments", "invoices"]
797            .iter()
798            .map(|t| format!("CREATE TABLE {t} (\n    id BIGSERIAL PRIMARY KEY,\n    status TEXT NOT NULL DEFAULT 'pending',\n    created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\nCREATE INDEX idx_{t}_created_at ON {t} (created_at);\n"))
799            .collect();
800        let pem: String = (0..20).map(|i| CacheManager::sha256_hex(format!("pem {i}").as_bytes()) + "\n").collect();
801        let creds: String = ["stripe", "sendgrid", "github", "datadog", "sentry"]
802            .iter()
803            .map(|s| format!("{s}_api_key: sk_test_{}\n{s}_endpoint: https://{s}.example.com/v1\n", &CacheManager::sha256_hex(s.as_bytes())[..32]))
804            .collect();
805        vec![
806            ("panic", format!("thread 'main' panicked at src/scheduler/queue.rs:217:31:\ncalled `Option::unwrap()` on a `None` value\nstack backtrace:\n{frames}")),
807            ("migration", format!("BEGIN;\n{tables}ALTER TABLE refunds ADD COLUMN reason TEXT;\nCOMMIT;\n")),
808            ("keys", format!("-----BEGIN RSA PRIVATE KEY-----\n{pem}-----END RSA PRIVATE KEY-----\n{creds}")),
809        ]
810    }
811
812    #[test]
813    fn high_risk_content_passes_through_unchanged() {
814        use crate::confidence_router::CompressionMode;
815        let dir = tempfile::tempdir().unwrap();
816        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
817        let inputs = high_risk_inputs();
818        for (name, input) in &inputs {
819            assert!(input.len() > 500, "{name}");
820            assert_eq!(engine.route_compression_mode(input), CompressionMode::Safe, "{name}");
821            assert_eq!(&engine.compress(input).unwrap().data, input, "{name}");
822            for mode in [CompressionMode::Safe, CompressionMode::Default, CompressionMode::Aggressive] {
823                assert_eq!(&engine.compress_with_mode(input, mode).unwrap().data, input, "{name} {mode:?}");
824            }
825        }
826        let colored = format!("\x1b[31m{}\x1b[0m", inputs[0].1);
827        assert_eq!(engine.compress(&colored).unwrap().data, inputs[0].1);
828    }
829
830    #[test]
831    fn verifier_fallback_still_compresses() {
832        use crate::confidence_router::CompressionMode;
833        let dir = tempfile::tempdir().unwrap();
834        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
835        let input = (0..24)
836            .map(|i| {
837                if i % 2 == 0 {
838                    format!("SEG{i} /srv/app/mod_{i}.py llllllllllllllllllllllllllllllllllllllll")
839                } else {
840                    format!("SEG{i} the quick brown fox jumps over {i} lazy dogs by silver rivers")
841                }
842            })
843            .collect::<Vec<_>>()
844            .join("\n\n");
845        assert_ne!(engine.route_compression_mode(&input), CompressionMode::Safe);
846        let result = engine.compress(&input).unwrap();
847        assert_eq!(result.provenance.label.as_deref(), Some("safe-fallback"));
848        assert!(result.data.contains("low-information segments omitted"), "{}", result.data);
849        assert_eq!(result.data, engine.compress_with_mode(&input, CompressionMode::Safe).unwrap().data);
850    }
851
852    #[test]
853    fn json_values_survive_every_mode() {
854        use crate::confidence_router::CompressionMode;
855        let dir = tempfile::tempdir().unwrap();
856        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
857        let pretty = "[\n  0,\n  0,\n  0,\n  0,\n  0,\n  0,\n  1\n]\n";
858        let expected = serde_json::json!([0, 0, 0, 0, 0, 0, 1]);
859        let mut outputs = vec![engine.compress(pretty).unwrap().data];
860        for mode in [CompressionMode::Safe, CompressionMode::Default, CompressionMode::Aggressive] {
861            outputs.push(engine.compress_with_mode(pretty, mode).unwrap().data);
862        }
863        for data in outputs {
864            assert_eq!(crate::toon::ToonEncoder.decode(&data).unwrap(), expected, "{data}");
865        }
866    }
867
868    #[test]
869    fn never_stored_content_skips_every_cache() {
870        use crate::cache_manager::CacheResult;
871        use crate::confidence_router::CompressionMode;
872        let dir = tempfile::tempdir().unwrap();
873        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
874        let mut log: String = (0..120)
875            .map(|i| format!("2026-10-05T12:{:02}:{:02}Z INFO  GET /healthz 200 1ms\n", i / 60, i % 60))
876            .collect();
877        log.push_str("2026-10-05T12:30:00Z DEBUG db config host=db.internal user=app password=hunter2\n");
878        assert_ne!(engine.route_compression_mode(&log), CompressionMode::Safe);
879        assert!(engine.never_store(&log));
880        let stored = || engine.cache_manager().check_dedup_with_meta(log.as_bytes()).unwrap().is_some();
881
882        let CacheResult::Fresh { output } = engine.compress_with_cache(&log).unwrap() else { panic!("not fresh") };
883        assert!(output.data.len() < log.len());
884        let CacheResult::Fresh { output } = engine.compress_with_cache_lossless(&log).unwrap() else { panic!("not fresh") };
885        assert_eq!(output.data, log);
886        let result = engine.compress_tool_result(&log, true).expect("compresses");
887        assert!(!result.repeat && !result.text.contains("sqz_expand"), "{}", result.text);
888        assert!(!stored());
889        assert!(matches!(engine.compress_with_cache(&log).unwrap(), CacheResult::Fresh { .. }));
890    }
891
892    #[test]
893    fn test_export_import_ctx_round_trip() {
894        let dir = tempfile::tempdir().unwrap();
895        let store_path = dir.path().join("store.db");
896        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
897
898        let session = make_session("sess-rt");
899        engine.session_store().save_session(&session).unwrap();
900
901        let ctx = engine.export_ctx("sess-rt").unwrap();
902        let imported_id = engine.import_ctx(&ctx).unwrap();
903        assert_eq!(imported_id, "sess-rt");
904    }
905
906    #[test]
907    fn test_search_sessions() {
908        let dir = tempfile::tempdir().unwrap();
909        let store_path = dir.path().join("store.db");
910        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
911
912        let mut session = make_session("sess-search");
913        session.compressed_summary = "authentication refactor".to_string();
914        engine.session_store().save_session(&session).unwrap();
915
916        let results = engine.search_sessions("authentication").unwrap();
917        assert_eq!(results.len(), 1);
918        assert_eq!(results[0].id, "sess-search");
919    }
920
921    #[test]
922    fn test_usage_report_starts_at_zero() {
923        let dir = tempfile::tempdir().unwrap();
924        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
925        let report = engine.usage_report("default");
926        assert_eq!(report.consumed, 0);
927        assert_eq!(report.available, report.allocated);
928    }
929
930    #[test]
931    fn test_cost_summary() {
932        let dir = tempfile::tempdir().unwrap();
933        let store_path = dir.path().join("store.db");
934        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
935
936        let session = make_session("sess-cost");
937        engine.session_store().save_session(&session).unwrap();
938
939        let summary = engine.cost_summary("sess-cost").unwrap();
940        assert_eq!(summary.total_tokens, 0);
941        assert!((summary.total_usd - 0.0).abs() < f64::EPSILON);
942    }
943
944    #[test]
945    fn test_reload_preset_updates_state() {
946        let dir = tempfile::tempdir().unwrap();
947        let mut engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
948        let toml = r#"
949[preset]
950name = "reloaded"
951version = "2.0"
952
953[compression]
954stages = []
955
956[tool_selection]
957max_tools = 5
958similarity_threshold = 0.7
959
960[budget]
961warning_threshold = 0.70
962ceiling_threshold = 0.85
963default_window_size = 200000
964
965[terse_mode]
966enabled = false
967level = "moderate"
968
969[model]
970family = "anthropic"
971primary = "claude-sonnet-4-20250514"
972complexity_threshold = 0.4
973"#;
974        assert!(engine.reload_preset(toml).is_ok());
975        // Verify the preset was actually updated
976        let preset = engine.preset.lock().unwrap();
977        assert_eq!(preset.preset.name, "reloaded");
978    }
979
980    #[test]
981    fn test_reload_invalid_preset_returns_error() {
982        let dir = tempfile::tempdir().unwrap();
983        let mut engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
984        let result = engine.reload_preset("not valid toml [[[");
985        assert!(result.is_err(), "invalid TOML should return error");
986    }
987
988    #[test]
989    fn test_export_nonexistent_session_returns_error() {
990        let dir = tempfile::tempdir().unwrap();
991        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
992        let result = engine.export_ctx("does-not-exist");
993        assert!(result.is_err());
994    }
995
996    #[test]
997    fn test_import_invalid_ctx_returns_error() {
998        let dir = tempfile::tempdir().unwrap();
999        let engine = SqzEngine::with_preset_and_store(Preset::default(), &dir.path().join("store.db")).unwrap();
1000        let result = engine.import_ctx("not valid json {{{");
1001        assert!(result.is_err());
1002    }
1003
1004    #[test]
1005    fn store_path_env_override_used_when_set() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let target = dir.path().join("nested").join("project.db");
1008        let resolved =
1009            SqzEngine::store_path_from_env(Some(target.to_string_lossy().to_string()));
1010        assert_eq!(resolved, Some(target.clone()));
1011        // Parent directory is created so SQLite can open the file.
1012        assert!(target.parent().unwrap().exists());
1013    }
1014
1015    #[test]
1016    fn store_path_env_ignores_empty_values() {
1017        assert_eq!(SqzEngine::store_path_from_env(None), None);
1018        assert_eq!(SqzEngine::store_path_from_env(Some(String::new())), None);
1019        assert_eq!(SqzEngine::store_path_from_env(Some("   ".to_string())), None);
1020    }
1021
1022    #[test]
1023    fn store_path_env_leaves_existing_parent_permissions_alone() {
1024        let dir = tempfile::tempdir().unwrap();
1025        #[cfg(unix)]
1026        {
1027            use std::os::unix::fs::PermissionsExt;
1028            std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755))
1029                .unwrap();
1030        }
1031        let target = dir.path().join("sessions.db");
1032        let resolved =
1033            SqzEngine::store_path_from_env(Some(target.to_string_lossy().to_string()));
1034        assert_eq!(resolved, Some(target));
1035        #[cfg(unix)]
1036        {
1037            use std::os::unix::fs::PermissionsExt;
1038            let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
1039            assert_eq!(mode, 0o755, "pre-existing parent must not be chmodded");
1040        }
1041    }
1042
1043    #[test]
1044    fn ref_ttl_env_parses_seconds_and_falls_back() {
1045        use std::time::Duration;
1046        // Unset, empty, whitespace, garbage → 30-minute default.
1047        assert_eq!(SqzEngine::ref_ttl_from_env(None), Duration::from_secs(1800));
1048        assert_eq!(
1049            SqzEngine::ref_ttl_from_env(Some(String::new())),
1050            Duration::from_secs(1800)
1051        );
1052        assert_eq!(
1053            SqzEngine::ref_ttl_from_env(Some("  ".into())),
1054            Duration::from_secs(1800)
1055        );
1056        assert_eq!(
1057            SqzEngine::ref_ttl_from_env(Some("ten".into())),
1058            Duration::from_secs(1800)
1059        );
1060        assert_eq!(
1061            SqzEngine::ref_ttl_from_env(Some("-5".into())),
1062            Duration::from_secs(1800)
1063        );
1064        // Valid values are honored, including 0 (disable refs entirely).
1065        assert_eq!(
1066            SqzEngine::ref_ttl_from_env(Some("300".into())),
1067            Duration::from_secs(300)
1068        );
1069        assert_eq!(
1070            SqzEngine::ref_ttl_from_env(Some(" 60 ".into())),
1071            Duration::from_secs(60)
1072        );
1073        assert_eq!(
1074            SqzEngine::ref_ttl_from_env(Some("0".into())),
1075            Duration::ZERO
1076        );
1077    }
1078
1079    #[test]
1080    fn engine_with_custom_store_path_round_trips() {
1081        let dir = tempfile::tempdir().unwrap();
1082        let db = dir.path().join("proj").join("sessions.db");
1083        let resolved = SqzEngine::store_path_from_env(Some(db.to_string_lossy().to_string()))
1084            .unwrap();
1085        let engine = SqzEngine::with_preset_and_store(Preset::default(), &resolved).unwrap();
1086        engine
1087            .session_store()
1088            .log_compression(100, 40, &[], "test")
1089            .unwrap();
1090        assert!(db.exists(), "database file must land at the custom path");
1091        let stats = engine.session_store().compression_stats().unwrap();
1092        assert_eq!(stats.total_compressions, 1);
1093    }
1094}