Skip to main content

lean_ctx_proxy/
compress.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4
5use super::ccr;
6use super::prose_compress::compress_prose;
7use lean_ctx::core::archive::{RecoveryId, RecoveryRefusal, delivered};
8use lean_ctx::core::tokens::{COUNTING_FAMILY, TokenizerFamily, count_tokens_for};
9use lean_ctx::core::web::distill;
10
11#[path = "compress_gate.rs"]
12pub(crate) mod gate;
13
14/// Byte-ish budget for the research-prose squeeze (~5k tokens on English prose).
15/// Only oversized prose is truncated; the squeeze's main job is dedup + blank-collapse,
16/// not cutting.
17const RESEARCH_PROSE_CAP: usize = 20_000;
18const RESEARCH_PROSE_CAP_ENV: &str = "LEAN_CTX_RESEARCH_PROSE_CAP";
19
20fn research_prose_cap() -> usize {
21    std::env::var(RESEARCH_PROSE_CAP_ENV)
22        .ok()
23        .and_then(|v| v.trim().parse::<usize>().ok())
24        .filter(|cap| *cap > 0)
25        .unwrap_or(RESEARCH_PROSE_CAP)
26}
27
28/// Proxy compression funnel: routes a tool result to the right compressor.
29///
30/// 1. Already-cited research output (from `ctx_url_read` / the web layer) is kept
31///    verbatim — it is distilled and citation-stamped, so the shell pipeline must
32///    not touch its footer or claim markers.
33/// 2. Prose results (web fetches, doc reads, research MCP bridges) are squeezed
34///    by the prose-aware research compressor instead of the log/code-tuned shell
35///    engine.
36/// 3. Everything else (shell/build/search output) flows through the unified
37///    `compress_if_beneficial` pipeline. A `$ ...` command hint is extracted so
38///    the pattern engine gets the same routing as the CLI and MCP paths.
39pub fn compress_tool_result(content: &str, tool_name: Option<&str>) -> String {
40    compress_tool_result_for(content, tool_name, COUNTING_FAMILY)
41}
42
43/// Compresses a tool result under a task-aware policy.
44pub fn compress_tool_result_with_policy(
45    content: &str,
46    tool_name: Option<&str>,
47    policy: lean_ctx::core::adaptive_policy::CompressionPolicy,
48) -> String {
49    if !policy.compress_tool_output || policy.log_compression_level == 0 {
50        return content.to_owned();
51    }
52    if policy.code_preserve && looks_like_code(content) {
53        return content.to_owned();
54    }
55    compress_tool_result(content, tool_name)
56}
57
58/// Gateway variant of [`compress_tool_result_with_policy`].
59pub fn compress_tool_result_gateway_with_policy(
60    content: &str,
61    tool_name: Option<&str>,
62    family: TokenizerFamily,
63    policy: lean_ctx::core::adaptive_policy::CompressionPolicy,
64) -> String {
65    if !policy.compress_tool_output || policy.log_compression_level == 0 {
66        return content.to_owned();
67    }
68    if policy.code_preserve && looks_like_code(content) {
69        return content.to_owned();
70    }
71    compress_tool_result_gateway_for(content, tool_name, family)
72}
73
74fn looks_like_code(content: &str) -> bool {
75    content.lines().any(|line| {
76        let line = line.trim_start();
77        ["fn ", "struct ", "class ", "function ", "def ", "impl "]
78            .iter()
79            .any(|p| line.starts_with(p))
80            || line.contains('{')
81                && line.contains('}')
82                && (line.contains(';') || line.contains("=>"))
83    })
84}
85
86pub fn compress_tool_result_for(
87    content: &str,
88    tool_name: Option<&str>,
89    family: TokenizerFamily,
90) -> String {
91    if carries_recovery_stub(content) {
92        return content.to_owned();
93    }
94    let compressed = compress_inner(content, tool_name, family);
95    attach_ccr(content, compressed, CcrAudience::Local)
96}
97
98/// Shape the output of a host-native shell command whose exact command line is
99/// known (`ctx_shape`, fed by a host mod after the native tool ran). Unlike the
100/// wire funnel, the real command reaches the engine, so its command-gated
101/// policies (protected/verbatim commands, build/test guards, per-tool patterns)
102/// apply exactly as for `ctx_shell`. With a known `exit_code` the outcome-aware
103/// path runs (success compresses, failure keeps its diagnostics); without one
104/// the engine's unknown-outcome guard applies. Lossy results carry the same
105/// deterministic CCR recovery handle (#482, #498).
106///
107/// The same rules as the wire funnel hold: an output that already ends with
108/// its way back is returned whole, and every shorter form leaves through
109/// `attach_ccr` — under protection only with a recovery copy bound to the
110/// output's lineage, otherwise whole and noted with its code.
111///
112/// This route is no request: it holds no tool list of the host that will
113/// read the result. It therefore runs as a pass that knows no expand tool
114/// (`sticky_tools::in_pass`) instead of outside any pass, where the bare
115/// name `ctx_expand` would stand in. Under protection no stub is issued
116/// then: the output stays whole with `recovery.handle_not_followable`, and
117/// nothing is stored. Unprotected, the Community tee handle is unchanged.
118pub fn shape_command_output(command: &str, output: &str, exit_code: Option<i32>) -> String {
119    if output.trim().is_empty() || output.len() < 200 {
120        return output.to_string();
121    }
122    if carries_recovery_stub(output) {
123        return output.to_string();
124    }
125    let compressed = match exit_code {
126        Some(code) => {
127            lean_ctx::shell::compress::engine::compress_for_outcome(command, output, code)
128        }
129        None => lean_ctx::shell::compress::engine::compress_if_beneficial_pub(command, output),
130    };
131    super::sticky_tools::in_pass(|| attach_ccr(output, compressed, CcrAudience::Local))
132}
133
134/// [`compress_tool_result`] for a caller that is no request of this proxy:
135/// the output of a host-native tool handed in after it ran (`ctx_shape`,
136/// every tool whose command line is not known).
137///
138/// Such a caller holds no tool list of the host that will read the result,
139/// so no expand tool is known to be followable there. The compression runs
140/// as a pass that knows no tool (`sticky_tools::in_pass`), never outside any
141/// pass, where the bare name `ctx_expand` would stand in. Under protection
142/// no stub is issued then: the text stays whole with
143/// `recovery.handle_not_followable`, and nothing is stored. Unprotected, the
144/// result and its Community tee handle are those of [`compress_tool_result`].
145pub fn shape_tool_output(output: &str, tool_name: Option<&str>) -> String {
146    super::sticky_tools::in_pass(|| compress_tool_result(output, tool_name))
147}
148
149/// [`compress_tool_result`] for the `/v1/compress` gateway contract (#702):
150/// identical compression, but a lossy result advertises its retrieval hash in
151/// LiteLLM's regex-locked `hash=<24hex>` form, so a gateway running the
152/// headroom-guardrail CCR loop (BerriAI/litellm#31681) can inject its retrieve
153/// tool and resolve the original via `GET /v1/retrieve/{hash}`.
154///
155/// Any ambient savings footer is stripped *before* the marker is attached:
156/// savings figures belong in the caller's structured `stats` (#498), while the
157/// marker is functional content that must survive as the last line.
158pub fn compress_tool_result_gateway(content: &str, tool_name: Option<&str>) -> String {
159    compress_tool_result_gateway_for(content, tool_name, COUNTING_FAMILY)
160}
161
162pub fn compress_tool_result_gateway_for(
163    content: &str,
164    tool_name: Option<&str>,
165    family: TokenizerFamily,
166) -> String {
167    // Before the footer strip below: a local stub has the footer's shape.
168    if carries_recovery_stub(content) {
169        return content.to_owned();
170    }
171    let compressed = compress_inner(content, tool_name, family);
172    let clean = lean_ctx::core::protocol::strip_trailing_savings_footer(&compressed).to_string();
173    attach_ccr(content, clean, CcrAudience::Gateway)
174}
175
176/// Who reads a CCR stub: a local agent (path handle / in-band marker) or a
177/// remote gateway whose agentic loop scans for `hash=<24hex>` (#702).
178#[derive(Clone, Copy, PartialEq)]
179enum CcrAudience {
180    Local,
181    Gateway,
182}
183
184/// Who reads what a compression pass sends, as the route that runs the pass
185/// knows it ([`observing`]).
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub(crate) enum Reader {
188    /// A model behind a provider: it can call `ctx_expand`, never a route of
189    /// this proxy.
190    #[cfg(feature = "http-server")]
191    Model,
192    /// The caller of `/v1/compress`: it can call this proxy's routes.
193    GatewayCaller,
194}
195
196/// The blocks a compression pass left whole under protection because no
197/// recovery copy could back a shorter form, and why. Content-free towards
198/// every reader: a block is held by its digest only to count it once, and
199/// only codes and counts leave this type.
200#[derive(Debug, Default, Clone, PartialEq, Eq)]
201pub(crate) struct KeptWhole {
202    blocks: BTreeMap<[u8; 32], &'static str>,
203}
204
205impl KeptWhole {
206    /// The first code noted for a block stands: a later pass over the same
207    /// bytes does not count it again.
208    fn note(&mut self, block: &str, code: &'static str) {
209        self.blocks
210            .entry(*blake3::hash(block.as_bytes()).as_bytes())
211            .or_insert(code);
212    }
213
214    #[cfg(feature = "http-server")]
215    pub(crate) fn merge(&mut self, other: Self) {
216        for (block, code) in other.blocks {
217            self.blocks.entry(block).or_insert(code);
218        }
219    }
220
221    #[cfg(feature = "http-server")]
222    pub(crate) fn is_empty(&self) -> bool {
223        self.blocks.is_empty()
224    }
225
226    /// Blocks per code, in code order.
227    #[cfg(feature = "http-server")]
228    pub(crate) fn counts(&self) -> BTreeMap<&'static str, usize> {
229        let mut counts = BTreeMap::new();
230        for code in self.blocks.values() {
231            *counts.entry(*code).or_insert(0) += 1;
232        }
233        counts
234    }
235
236    /// The distinct codes, comma-separated in code order — the value of the
237    /// `x-leanctx-reason-codes` header of a reply that was NOT refused.
238    /// `None`: nothing stayed whole for want of a recovery copy.
239    #[cfg(feature = "http-server")]
240    pub(crate) fn codes(&self) -> Option<String> {
241        let counts = self.counts();
242        (!counts.is_empty()).then(|| counts.keys().copied().collect::<Vec<_>>().join(","))
243    }
244
245    /// `code=count` pairs for a log line.
246    #[cfg(feature = "http-server")]
247    pub(crate) fn summary(&self) -> String {
248        self.counts()
249            .iter()
250            .map(|(code, count)| format!("{code}={count}"))
251            .collect::<Vec<_>>()
252            .join(", ")
253    }
254}
255
256struct Observation {
257    reader: Reader,
258    kept: KeptWhole,
259}
260
261thread_local! {
262    /// The compression pass running on this thread, if a route observes it.
263    /// Compression is synchronous from the route down to [`attach_ccr`].
264    static OBSERVATION: RefCell<Option<Observation>> = const { RefCell::new(None) };
265}
266
267/// Run one synchronous compression pass for `reader` and return, next to its
268/// result, the blocks it left whole under protection. An inner pass reports
269/// to its own caller only.
270#[cfg(feature = "http-server")]
271pub(crate) fn observing<T>(reader: Reader, compression: impl FnOnce() -> T) -> (T, KeptWhole) {
272    struct Restore(Option<Observation>);
273    impl Drop for Restore {
274        fn drop(&mut self) {
275            let outer = self.0.take();
276            OBSERVATION.with(|slot| *slot.borrow_mut() = outer);
277        }
278    }
279    let restore = Restore(OBSERVATION.with(|slot| {
280        slot.replace(Some(Observation {
281            reader,
282            kept: KeptWhole::default(),
283        }))
284    }));
285    let result = compression();
286    let kept = OBSERVATION
287        .with(|slot| slot.borrow_mut().take())
288        .map_or_else(KeptWhole::default, |observation| observation.kept);
289    drop(restore);
290    (result, kept)
291}
292
293/// The reader of the pass running on this thread; `None` outside a route.
294fn observed_reader() -> Option<Reader> {
295    OBSERVATION.with(|slot| slot.borrow().as_ref().map(|observation| observation.reader))
296}
297
298/// `block` stays whole under protection for the content-free reason `code`.
299/// Counted for the route that observes the pass; logged otherwise.
300pub(crate) fn note_kept_whole(block: &str, code: &'static str) {
301    OBSERVATION.with(|slot| {
302        if let Some(observation) = slot.borrow_mut().as_mut() {
303            observation.kept.note(block, code);
304        } else {
305            tracing::debug!("lean-ctx proxy: block kept whole, no recovery copy ({code})");
306        }
307    });
308}
309
310/// Whether nothing may be shortened without a recovery copy bound to its
311/// lineage: a policy pack, governed or sovereign mode, or an unloadable
312/// configuration.
313pub(crate) fn protected() -> bool {
314    lean_ctx::core::context_admission::stored::source_authority_required()
315}
316
317/// How a lineage-bound recovery stub opens. Deliberately not the
318/// savings-footer shape (`[lean-ctx: `): a footer strip must never eat it.
319///
320/// The grammar of the stub, stated once: one line,
321/// `[lean-ctx recovery: full original archived — <tool>(id="<64 hex>",
322/// head=N|search="…"|json_path="…") for a slice]`. `<tool>` is the Engine's
323/// expand tool under the name the host of the request registered it with
324/// ([`expand_tool_name`]: at most 128 characters of `[A-Za-z0-9_.:/-]`), so
325/// it can hold neither `]` nor a line break. Everything that recognises a
326/// stub ([`carries_recovery_stub`]) reads the fixed opening and the closing
327/// bracket only, whatever the tool is called.
328const RECOVERY_STUB_OPEN: &str = "[lean-ctx recovery: ";
329
330/// The Engine tool that resolves a recovery id, by its bare name.
331const EXPAND_TOOL: &str = "ctx_expand";
332
333/// The last line of a block that was compressed under protection, for a
334/// model: `tool` — the Engine's expand tool as the host registered it —
335/// resolves the 64-hex `id` under the authority that holds then. A pure
336/// function of the name and the id, so the block stays byte-stable while the
337/// host keeps the name.
338pub(crate) fn recovery_stub_for(tool: &str, id: &RecoveryId) -> String {
339    format!(
340        "{RECOVERY_STUB_OPEN}full original archived — \
341         {tool}(id=\"{}\", head=N|search=\"…\"|json_path=\"…\") for a slice]",
342        id.as_str()
343    )
344}
345
346/// [`recovery_stub_for`] with the tool's bare name.
347#[cfg(test)]
348pub(crate) fn recovery_stub(id: &RecoveryId) -> String {
349    recovery_stub_for(EXPAND_TOOL, id)
350}
351
352/// [`recovery_stub_for`] for the caller of `/v1/compress`, which can follow a
353/// route of this proxy. No `hash=`: the id is not a tee hash, and a LiteLLM
354/// guardrail must not send it to `/v1/retrieve`.
355fn reference_stub(id: &RecoveryId) -> String {
356    format!(
357        "[lean-ctx CCR: full original archived — GET /v1/references/{} on this gateway \
358         returns it while its sources are admitted]",
359        id.as_str()
360    )
361}
362
363/// Upper bound of the length in bytes of a stub with the bare tool name: a
364/// shrink below it gains nothing. A stub with a longer, prefixed name is
365/// caught where it is attached — a block that would not get shorter with its
366/// stub stays as it is.
367const STUB_MAX_BYTES: usize = 256;
368
369/// Text that already ends with a way back to its original. A second pass
370/// passes it through: compressing it again could drop the handle, and the
371/// local stubs have the shape of a savings footer the gateway funnel strips.
372fn carries_recovery_stub(content: &str) -> bool {
373    let last = content
374        .trim_end_matches('\n')
375        .rsplit('\n')
376        .next()
377        .unwrap_or_default();
378    last.ends_with(']')
379        && [
380            RECOVERY_STUB_OPEN,
381            "[lean-ctx CCR: full original ",
382            "[lean-ctx: full original ",
383        ]
384        .iter()
385        .any(|open| last.starts_with(open))
386}
387
388/// Make a live-compressed `tool_result` non-lossy (#482): when compression
389/// removed a meaningful amount, tee the verbatim original to the shared
390/// content-addressed store and append a deterministic recovery handle. The
391/// handle is a pure function of the content hash, so the rewritten result is
392/// byte-stable across turns and never invalidates the provider cache prefix
393/// (#448). Passthrough / verbatim results (no real shrink) keep their bytes.
394/// When no recovery copy can be written (an active policy keeps no originless
395/// copies, or the store fails) the original is returned instead of the lossy
396/// form: a fresh result is never shortened without a way back.
397fn attach_ccr(original: &str, result: String, audience: CcrAudience) -> String {
398    use super::sticky_tools;
399    if result == original {
400        return result;
401    }
402    let shrink = original.len().saturating_sub(result.len());
403    if original.len() < ccr::MIN_TEE_BYTES || shrink < ccr::MIN_TEE_BYTES {
404        // Too small for a tee: unprotected, the result leaves as it is.
405        if !protected() {
406            return result;
407        }
408        // Under protection a changed block leaves only with the stub of a
409        // recovery copy. Where the stub costs more than this pass saved,
410        // nothing is gained and nothing is changed — not a refusal, no code.
411        if shrink <= STUB_MAX_BYTES {
412            return original.to_string();
413        }
414    }
415    // The gateway funnel on a route whose reader is a model (the forward
416    // pipeline) or is unknown: neither the tee hash nor a proxy route is a
417    // way back that reader can follow. Neither is a stub naming a tool the
418    // host of this request did not list itself, or — under protection — a
419    // stub for a block whose own tool call is not on record in this store
420    // as answered by an Engine of this store (`sticky_tools::expand_tool_for`
421    // states the whole decision). Decided before anything is stored.
422    let tool = (audience == CcrAudience::Local)
423        .then(|| sticky_tools::expand_tool_for(original))
424        .flatten();
425    let followable =
426        tool.is_some() || observed_reader() == Some(Reader::GatewayCaller) || !protected();
427    if !followable {
428        note_kept_whole(original, HANDLE_NOT_FOLLOWABLE);
429        return original.to_string();
430    }
431    match recovery_copy(original) {
432        Some(RecoveryCopy::Bound {
433            id,
434            lineage,
435            project,
436        }) => {
437            let stub = if audience == CcrAudience::Local {
438                recovery_stub_for(tool.as_deref().unwrap_or(EXPAND_TOOL), &id)
439            } else {
440                reference_stub(&id)
441            };
442            let sent = format!("{result}\n{stub}");
443            if sent.len() >= original.len() {
444                return original.to_string();
445            }
446            // The one record a site other than an Engine release point makes
447            // (`delivered::record_derived`). Guaranteed here:
448            // 1. `lineage` came from `lineage_of` for exactly `original`, in
449            //    this request, inside the same project scope (`bound_copy`);
450            // 2. `sent` derives from `original` alone: `result` is the
451            //    compressor's output for that one block, and the rest is the
452            //    fixed stub text with the id of its recovery copy;
453            // 3. no second block takes part, so there is nothing to join —
454            //    a block without a lineage never reaches this arm.
455            let recorded = super::request_project::in_project(&project, || {
456                delivered::record_derived(&delivered::digest(&sent), &lineage)
457            });
458            if recorded {
459                // The model's stub names the expand tool the host listed:
460                // the request's pass holds the stubbed block next to its
461                // original until the request has left (`gate::Issued`). The
462                // gateway caller's stub names a route, not a tool.
463                if audience == CcrAudience::Local {
464                    gate::note_issued(original, &sent);
465                }
466                sent
467            } else {
468                // The shortened block would be of unknown lineage for every
469                // later turn; the original is not.
470                note_kept_whole(original, DERIVED_NOT_RECORDED);
471                original.to_string()
472            }
473        }
474        // Gateway (#702): the reader is a model behind a LiteLLM-style gateway.
475        // A local path is useless there; advertise the 24-hex retrieval hash in
476        // the exact shape the guardrail's marker regex captures. The hash is a
477        // pure function of the content, so the stub stays byte-stable (#498).
478        // The `[lean-ctx CCR:` prefix is deliberately NOT the savings-footer
479        // shape (`[lean-ctx: `): this line is functional content — a footer
480        // strip must never eat it.
481        Some(RecoveryCopy::Tee(handle)) if audience == CcrAudience::Gateway => {
482            sticky_tools::mark_ccr_active(0);
483            let hash = ccr::litellm_hash(original);
484            format!(
485                "{result}\n[lean-ctx CCR: full original elided to save tokens — call the \
486                 retrieve tool with hash={hash}, or read {handle} locally]"
487            )
488        }
489        Some(RecoveryCopy::Tee(handle)) => {
490            sticky_tools::mark_ccr_active(0);
491            match ccr::inband_locator(&handle) {
492                Some(marker) => format!(
493                    "{result}\n[lean-ctx: full original elided to save tokens — echo {marker} \
494                     on your next turn to get the verbatim original spliced back inline]"
495                ),
496                None => format!(
497                    "{result}\n[lean-ctx: full original at {handle} — read it directly (no MCP), or \
498                     ctx_expand(id=\"{handle}\", head=N|search=\"…\"|json_path=\"…\") for a slice]"
499                ),
500            }
501        }
502        None => original.to_string(),
503    }
504}
505
506/// The one place a compressed result gets its way back: store a recovery
507/// copy of `original` and return its handle, or `None` when no copy may
508/// exist — in which case the caller forwards the original instead of the
509/// lossy form.
510///
511/// - Unprotected (Community, developer mode): the originless tee store, as
512///   before.
513/// - Protected (a policy pack, governed or sovereign mode, an unloadable
514///   configuration): a copy exists only for a block whose sources are known
515///   ([`bound_copy`]). The proxy only sees a request body and observed no
516///   read; permission to read is not permission to retain. Without a known,
517///   currently admitted lineage there is no copy and the caller keeps the
518///   original.
519fn recovery_copy(original: &str) -> Option<RecoveryCopy> {
520    if !lean_ctx::core::context_admission::stored::source_authority_required() {
521        return ccr::persist(original).map(RecoveryCopy::Tee);
522    }
523    match bound_copy(original) {
524        Ok(copy) => Some(copy),
525        Err(code) => {
526            note_kept_whole(original, code);
527            None
528        }
529    }
530}
531
532/// The gate of every transformation other than the compressor that replaces
533/// a block by a shorter form under protection (history pruning, truncation):
534/// `shortened` followed by the stub of a recovery copy of `original`, on
535/// record under the original's lineage — or `None`, and the caller keeps
536/// `original` whole. `None` is noted with its code, except where the form
537/// with its stub would not be shorter at all.
538pub(crate) fn shorten_with_copy(original: &str, shortened: &str) -> Option<String> {
539    // Decided before anything is stored: a request whose host did not list
540    // the expand tool itself hands out no stub that names it, and under
541    // protection neither does text that is not one tool-result block whose
542    // call this store's Engine answered (`sticky_tools::expand_tool_for`).
543    let Some(tool) = super::sticky_tools::expand_tool_for(original) else {
544        note_kept_whole(original, HANDLE_NOT_FOLLOWABLE);
545        return None;
546    };
547    let copy = match bound_copy(original) {
548        Ok(copy) => copy,
549        Err(code) => {
550            note_kept_whole(original, code);
551            return None;
552        }
553    };
554    let RecoveryCopy::Bound {
555        id,
556        lineage,
557        project,
558    } = copy
559    else {
560        return None;
561    };
562    let sent = format!("{shortened}\n{}", recovery_stub_for(&tool, &id));
563    if sent.len() >= original.len() {
564        return None;
565    }
566    // `record_derived`, as in `attach_ccr`: the lineage is that of exactly
567    // `original`, looked up in this request and scope; `sent` is a cut of
568    // that one block plus fixed text of the cutting code and the stub.
569    let recorded = super::request_project::in_project(&project, || {
570        delivered::record_derived(&delivered::digest(&sent), &lineage)
571    });
572    if recorded {
573        // This stub names the host's expand tool as well (see `attach_ccr`).
574        gate::note_issued(original, &sent);
575        Some(sent)
576    } else {
577        note_kept_whole(original, DERIVED_NOT_RECORDED);
578        None
579    }
580}
581
582/// A block that an earlier request already carried may not be replaced by a
583/// pointer to that request: nothing in THIS request holds its content.
584pub(crate) const DEDUP_WITHOUT_COPY: &str = "recovery.dedup_without_copy";
585/// The conversation may not be replaced by a summary: no copy backs it.
586#[cfg(feature = "http-server")]
587pub(crate) const SUMMARY_WITHOUT_COPY: &str = "recovery.summary_without_copy";
588
589/// The reader of this pass cannot follow any handle a copy would have.
590const HANDLE_NOT_FOLLOWABLE: &str = "recovery.handle_not_followable";
591/// The copy exists, but the shortened block could not be put on record
592/// under the lineage of its original.
593const DERIVED_NOT_RECORDED: &str = "recovery.derived_not_recorded";
594/// No project of the proxy that sources could be admitted for.
595const NO_PROJECT: &str = "recovery.no_project";
596/// The block is not text the Engine delivered with a complete lineage.
597const UNKNOWN_LINEAGE: &str = "recovery.unknown_lineage";
598
599/// The recovery copy of `original` under protection, or the content-free
600/// code of why there is none. Inside the scope of the proxy's own project
601/// (operator configuration, `request_project`): the Engine's lineage of
602/// exactly this text, and a copy stored under it — which the store accepts
603/// only while every source of that lineage is admitted now. A file-only
604/// lineage lands on the Engine's existing source-bound archive entry for the
605/// same text, so no second copy is written.
606fn bound_copy(original: &str) -> Result<RecoveryCopy, &'static str> {
607    let project = super::request_project::current_source_project().ok_or(NO_PROJECT)?;
608    super::request_project::in_project(&project, || {
609        let lineage = delivered::lineage_of(&delivered::digest(original))
610            .filter(delivered::Lineage::is_complete)
611            .ok_or(UNKNOWN_LINEAGE)?;
612        let id = lean_ctx::core::archive::store_recovery_with_lineage(original, &lineage)
613            .map_err(recovery_code)?;
614        Ok((id, lineage))
615    })
616    .map(|(id, lineage)| RecoveryCopy::Bound {
617        id,
618        lineage: Box::new(lineage),
619        project,
620    })
621}
622
623/// The content-free code of a recovery refusal.
624pub(crate) fn recovery_code(refusal: RecoveryRefusal) -> &'static str {
625    match refusal {
626        RecoveryRefusal::UnknownLineage => UNKNOWN_LINEAGE,
627        RecoveryRefusal::SourceNotPresented => "recovery.source_not_presented",
628        RecoveryRefusal::SourceNoLongerAdmitted => "recovery.source_no_longer_admitted",
629        RecoveryRefusal::NotStorable => "recovery.not_storable",
630        RecoveryRefusal::Missing => "recovery.missing",
631    }
632}
633
634/// Resolve a recovery id under the authority that holds now, for the proxy's
635/// own project. `None`: `id` is not a recovery id at all. Without a project
636/// nothing is looked up.
637#[cfg(feature = "http-server")]
638pub(crate) fn resolve_recovery(id: &str) -> Option<Result<String, &'static str>> {
639    let id = RecoveryId::parse(id)?;
640    let Some(project) = super::request_project::current_source_project() else {
641        // Unprotected deployments hold no recovery copies: not a recovery id.
642        return lean_ctx::core::context_admission::stored::source_authority_required()
643            .then_some(Err(NO_PROJECT));
644    };
645    Some(super::request_project::in_project(&project, || {
646        lean_ctx::core::archive::retrieve_recovery_checked(&id).map_err(recovery_code)
647    }))
648}
649
650/// Where the way back of a compressed result leads.
651enum RecoveryCopy {
652    /// A tee file of the originless store; the value is its handle.
653    Tee(String),
654    /// A copy in the Engine's archive, bound to the lineage of the original
655    /// and stored for `project`.
656    Bound {
657        id: RecoveryId,
658        lineage: Box<delivered::Lineage>,
659        project: std::path::PathBuf,
660    },
661}
662
663fn compress_inner(content: &str, tool_name: Option<&str>, family: TokenizerFamily) -> String {
664    if content.trim().is_empty() || content.len() < 200 {
665        return content.to_string();
666    }
667
668    // #479: lean-ctx's own MCP tools already applied their compression policy at
669    // the tool boundary — honouring `raw=true`/`bypass`, `<lc_safe>` spans and
670    // the configured aggressiveness. Their `raw` intent lives in the originating
671    // `tool_use` input, which is invisible here, so re-compressing on the wire
672    // would silently undo an explicit `raw=true` and double-compress everything
673    // else. Pass results from `ctx_*` tools through untouched.
674    if tool_name.is_some_and(is_lean_ctx_tool) {
675        return content.to_string();
676    }
677
678    // #709: honour explicit <lc_safe>…</lc_safe> spans on the proxy path too.
679    // Protected spans pass through verbatim; each unprotected segment flows back
680    // through the normal funnel (markers are stripped, so this never recurses).
681    if lean_ctx::core::protect::has_markers(content) {
682        return lean_ctx::core::protect::compress_preserving(content, |seg| {
683            compress_inner(seg, tool_name, family)
684        });
685    }
686
687    if is_cited_research_output(content) {
688        if looks_like_markdown(content) {
689            let prose = compress_prose(content, None);
690            if prose.compressed_tokens < prose.original_tokens {
691                return prose.compressed;
692            }
693        }
694        return content.to_string();
695    }
696
697    if extract_command_hint(content).is_none()
698        && looks_like_prose(content)
699        && let Some(out) = squeeze_research_prose(content, family)
700    {
701        return out;
702    }
703
704    let cmd = infer_command(content, tool_name);
705
706    // Proxy fidelity guard. A foreign shell tool gives us at most a generic
707    // command (`"shell"`) or none, so the engine's command-gated build/test
708    // verbatim guards never fire. When the *output* is unmistakably a build or
709    // test run, preserve it verbatim (bounded by safety-line-preserving
710    // truncation) so compiler errors, panics and test summaries reach the model
711    // intact — the exact signal a bug-fix task depends on.
712    let generic_command = cmd.is_empty() || cmd == "shell";
713    if generic_command
714        && (output_looks_like_test_run(content) || output_looks_like_build_failure(content))
715    {
716        return lean_ctx::shell::compress::engine::preserve_verbatim_pub_for(content, family);
717    }
718
719    lean_ctx::shell::compress::engine::compress_if_beneficial_for(&cmd, content, family)
720}
721
722/// Strong, ecosystem-spanning signals that an output is a *test run* (passing or
723/// failing). Conservative — matches the summary/result lines a bug-fix task must
724/// never lose. Only consulted on the proxy path when the real command is unknown.
725fn output_looks_like_test_run(content: &str) -> bool {
726    const NEEDLES: &[&str] = &[
727        "test result:",            // rust
728        "short test summary info", // pytest
729        " passed in ",             // pytest summary
730        " failed in ",             // pytest summary
731        "=== RUN",                 // go
732        "--- FAIL:",               // go
733        "--- PASS:",               // go
734        "Test Suites:",            // jest
735        " examples, ",             // rspec ("5 examples, 0 failures")
736        "FAILED",                  // generic test failure marker
737    ];
738    NEEDLES.iter().any(|n| content.contains(n))
739}
740
741/// Strong, specific signals of a build / compile / runtime failure across the
742/// major toolchains. Used only on the proxy path for generically-named tools so
743/// the failing diagnostics (paths, lines, messages) survive intact.
744fn output_looks_like_build_failure(content: &str) -> bool {
745    const NEEDLES: &[&str] = &[
746        "error[",                            // rustc (E0277 …)
747        ": error:",                          // gcc / clang "file.c:12:5: error:"
748        "fatal error:",                      // gcc / clang
749        "undefined reference to",            // linker
750        "panicked at",                       // rust runtime
751        "could not compile",                 // cargo
752        "Traceback (most recent call last)", // python
753        "AssertionError",                    // python / junit
754        "make: ***",                         // make
755        "Build FAILED",
756        "BUILD FAILED",
757        "Segmentation fault",
758    ];
759    NEEDLES.iter().any(|n| content.contains(n))
760}
761
762/// True when `content` is a lean-ctx web read: distilled body + citation footer
763/// (`Source: …\nSite: … · Retrieved: …`). Such output is re-compression-hostile.
764fn is_cited_research_output(content: &str) -> bool {
765    content.contains("· Retrieved: ") && content.contains("\nSource: ")
766}
767
768/// Code/shell symbols whose density cleanly separates source/logs from prose.
769const CODE_SYMBOLS: &str = "{}<>;=|\\$`";
770
771/// Conservative prose detector: substantial, letter-dense, low code-symbol, with
772/// real sentences and long lines. Code, logs, tables and JSON all fail this.
773fn looks_like_prose(content: &str) -> bool {
774    let sample: String = content.chars().take(4000).collect();
775    let total = sample.chars().count();
776    if total < 600 {
777        return false;
778    }
779    let total_f = total as f32;
780    let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
781    let spaces = sample.chars().filter(|c| *c == ' ').count() as f32;
782    let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
783
784    if alpha / total_f < 0.6 || spaces / total_f < 0.12 || symbols / total_f > 0.06 {
785        return false;
786    }
787    if sample.matches(['.', '!', '?']).count() < 4 {
788        return false;
789    }
790
791    let non_empty: Vec<&str> = sample.lines().filter(|l| !l.trim().is_empty()).collect();
792    if non_empty.is_empty() {
793        return false;
794    }
795    let avg_len =
796        non_empty.iter().map(|l| l.chars().count()).sum::<usize>() as f32 / non_empty.len() as f32;
797    avg_len >= 40.0
798}
799
800fn looks_like_markdown(content: &str) -> bool {
801    content.lines().any(|line| {
802        let trimmed = line.trim_start();
803        trimmed.starts_with("# ")
804            || trimmed.starts_with("## ")
805            || trimmed.starts_with("```")
806            || trimmed.starts_with("~~~")
807            || matches!(trimmed.as_bytes(), [b'-' | b'*' | b'+', b' ', ..])
808            || trimmed.matches('|').count() >= 2
809    })
810}
811
812/// Apply the prose squeeze, returning a footer-stamped result only when it
813/// actually saves tokens; otherwise `None` so the normal pipeline can try.
814fn squeeze_research_prose(content: &str, family: TokenizerFamily) -> Option<String> {
815    let before = count_tokens_for(content, family);
816    let squeezed = squeeze_research_prose_body(content);
817    if squeezed.trim().is_empty() {
818        return None;
819    }
820    let after = count_tokens_for(&squeezed, family);
821    if after + 2 >= before {
822        return None;
823    }
824    Some(lean_ctx::core::protocol::append_savings_with_info(
825        &squeezed,
826        before,
827        after,
828        Some("research"),
829        None,
830    ))
831}
832
833/// Choose the prose-squeeze body. Only when the content would actually be
834/// TRUNCATED (over the cap) do we upgrade from FIFO prefix truncation to
835/// extractive centrality ranking — which keeps the most representative sentences
836/// instead of just the first ones — via the cache-safe, memoized wire squeeze
837/// ([`crate::prose_ranker`]), so the cold→warm engine transition never
838/// changes a frozen-region rewrite (#448/#498). Below the cap the squeeze is a
839/// lossless dedup pass, so the cheaper truncating squeeze is used.
840fn squeeze_research_prose_body(content: &str) -> String {
841    let cap = research_prose_cap();
842    if content.len() > cap {
843        return super::prose_ranker::squeeze(content, cap);
844    }
845    distill::squeeze_prose(content, cap)
846}
847
848/// True when `name` refers to one of lean-ctx's own `ctx_*` MCP tools, whose
849/// results are already compressed at the tool boundary and must not be touched
850/// again by the proxy (#479).
851///
852/// Clients namespace MCP tools differently, so a plain `starts_with("ctx_")`
853/// misses the real-world callers: Claude Code (the reporter's setup) sends
854/// `mcp__lean-ctx__ctx_shell`, others use `lean-ctx:ctx_read`. Strip the client
855/// prefix down to the bare tool segment before matching.
856fn is_lean_ctx_tool(name: &str) -> bool {
857    bare_tool_name(name).starts_with("ctx_") || name.starts_with("ctx_")
858}
859
860/// The tool segment of a name a client namespaced: what follows the last
861/// `__` and, in that, the last `:`, `/` or `.`.
862pub(crate) fn bare_tool_name(name: &str) -> &str {
863    name.rsplit("__")
864        .next()
865        .unwrap_or(name)
866        .rsplit([':', '/', '.'])
867        .next()
868        .unwrap_or(name)
869}
870
871/// Whether `name` — a tool name as a host listed it — is the Engine's expand
872/// tool under a name a recovery stub can carry: the bare segment is
873/// `ctx_expand` (the stripping of [`is_lean_ctx_tool`]) and the whole name
874/// is at most 128 characters of `[A-Za-z0-9_.:/-]`, so it cannot break the
875/// one-line grammar of the stub. Anything else is not the tool.
876#[cfg(feature = "http-server")]
877pub(crate) fn expand_tool_name(name: &str) -> bool {
878    bare_tool_name(name) == EXPAND_TOOL
879        && name.len() <= 128
880        && name
881            .bytes()
882            .all(|byte| byte.is_ascii_alphanumeric() || b"_.:/-".contains(&byte))
883}
884
885fn infer_command(content: &str, tool_name: Option<&str>) -> String {
886    if let Some(cmd) = extract_command_hint(content) {
887        return cmd;
888    }
889
890    if let Some(name) = tool_name {
891        let nl = name.to_lowercase();
892        if nl.contains("bash") || nl.contains("shell") || nl.contains("terminal") {
893            return "shell".to_string();
894        }
895        if nl.contains("search") || nl.contains("grep") || nl.contains("find") {
896            return "grep".to_string();
897        }
898    }
899
900    String::new()
901}
902
903fn extract_command_hint(content: &str) -> Option<String> {
904    for line in content.lines().take(3) {
905        let trimmed = line.trim();
906        if let Some(cmd) = trimmed.strip_prefix("$ ") {
907            return Some(cmd.to_string());
908        }
909        if let Some(cmd) = trimmed.strip_prefix("% ") {
910            return Some(cmd.to_string());
911        }
912    }
913    None
914}
915
916#[cfg(test)]
917#[path = "compress_tests.rs"]
918mod tests;
919
920#[cfg(all(test, feature = "http-server"))]
921#[path = "compress_recovery_tests.rs"]
922mod recovery_tests;