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;