1use serde_json::Value;
47
48pub const THRESHOLD_BYTES: usize = 180_000;
51
52pub const MIN_ELIDE_BYTES: usize = 2_000;
55
56pub const STUB_OPEN: &str = "[lean-ctx: earlier tool output elided";
58
59fn serialized_len(messages: &[Value]) -> usize {
61 struct Count(usize);
62 impl std::io::Write for Count {
63 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
64 self.0 += buf.len();
65 Ok(buf.len())
66 }
67 fn flush(&mut self) -> std::io::Result<()> {
68 Ok(())
69 }
70 }
71 let mut count = Count(0);
72 let _ = serde_json::to_writer(&mut count, messages);
73 count.0
74}
75
76pub fn elide(messages: &mut [Value], threshold: usize) -> Option<usize> {
80 if serialized_len(messages) <= threshold {
81 return None;
82 }
83 let boundary = super::history_prune::prune_boundary(
84 lean_ctx::core::config::HistoryMode::CacheAware,
85 messages.len(),
86 );
87 let mut last = None;
88 for (index, message) in messages[..boundary].iter_mut().enumerate() {
89 if message.get("role").and_then(Value::as_str) != Some("user") {
90 continue;
91 }
92 let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) else {
93 continue;
94 };
95 for block in blocks {
96 if elide_block(block) {
97 last = Some(index);
98 }
99 }
100 }
101 last
102}
103
104fn elidable_text(content: &Value) -> Option<String> {
108 match content {
109 Value::String(text) => Some(text.clone()),
110 Value::Array(items) => {
111 let mut parts = Vec::with_capacity(items.len());
112 for item in items {
113 if item.get("type").and_then(Value::as_str) != Some("text")
114 || item.get("cache_control").is_some()
115 {
116 return None;
117 }
118 parts.push(item.get("text")?.as_str()?);
119 }
120 Some(parts.join("\n"))
121 }
122 _ => None,
123 }
124}
125
126fn elide_block(block: &mut Value) -> bool {
129 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
130 return false;
131 }
132 let Some(original) = block.get("content").and_then(elidable_text) else {
133 return false;
134 };
135 if original.len() <= MIN_ELIDE_BYTES
136 || original.starts_with(STUB_OPEN)
137 || original.contains("<protect>")
138 {
139 return false;
140 }
141 let chars = original.chars().count();
142 let stub = if super::compress::protected() {
143 let call_id = block.get("tool_use_id").and_then(Value::as_str);
144 let _block = super::sticky_tools::block(call_id);
145 let Some(stub) = super::compress::shorten_with_copy(
146 &original,
147 &format!("{STUB_OPEN} ({chars} chars).]"),
148 ) else {
149 return false;
150 };
151 stub
152 } else {
153 let Some(path) = super::ccr::persist_once(&original) else {
155 return false;
156 };
157 format!(
158 "{STUB_OPEN} ({chars} chars) to keep this long session lean; full text: {}]",
159 locator(std::path::Path::new(&path))
160 )
161 };
162 block["content"] = Value::String(stub);
163 true
164}
165
166fn locator(path: &std::path::Path) -> String {
170 let home = shell_home(|key| std::env::var_os(key), cfg!(windows));
171 locator_in(path, home.as_deref())
172}
173
174fn shell_home(
178 var: impl Fn(&str) -> Option<std::ffi::OsString>,
179 windows: bool,
180) -> Option<std::ffi::OsString> {
181 let set = |key: &str| var(key).filter(|value| !value.is_empty());
182 set("HOME").or_else(|| if windows { set("USERPROFILE") } else { None })
183}
184
185fn locator_in(path: &std::path::Path, home: Option<&std::ffi::OsStr>) -> String {
192 if let Some(home) = home
193 && let Ok(rest) = path.strip_prefix(home)
194 && let Some(rest) = home_relative(rest)
195 {
196 return format!("cat ~/{rest}");
197 }
198 let name = path
199 .file_name()
200 .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
201 format!("ctx_expand(id=\"{name}\") (LeanCTX state directory, tee/{name})")
202}
203
204fn home_relative(rest: &std::path::Path) -> Option<String> {
207 let mut parts = Vec::new();
208 for component in rest.components() {
209 match component {
210 std::path::Component::Normal(part) => parts.push(part.to_str()?),
211 _ => return None,
212 }
213 }
214 if parts.is_empty() {
215 return None;
216 }
217 let joined = parts.join("/");
218 let plain = |c: char| c.is_ascii_alphanumeric() || "._-/+@%,:=".contains(c);
219 if joined.chars().all(plain) {
220 return Some(joined);
221 }
222 if joined
225 .chars()
226 .any(|c| "\"$`\\!".contains(c) || c.is_control())
227 {
228 return None;
229 }
230 Some(format!("\"{joined}\""))
231}
232
233pub fn message_is_elided(message: &Value) -> bool {
235 message
236 .get("content")
237 .and_then(Value::as_array)
238 .is_some_and(|blocks| {
239 blocks.iter().any(|block| {
240 block.get("type").and_then(Value::as_str) == Some("tool_result")
241 && block
242 .get("content")
243 .and_then(Value::as_str)
244 .is_some_and(|text| text.starts_with(STUB_OPEN))
245 })
246 })
247}
248
249pub fn newly_elided(prefix_bytes: &[u8], last: usize) -> bool {
254 let Ok(previous) = serde_json::from_slice::<Value>(prefix_bytes) else {
255 return true;
256 };
257 previous
258 .get("messages")
259 .and_then(Value::as_array)
260 .and_then(|messages| messages.get(last))
261 .is_some_and(|message| !message_is_elided(message))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use serde_json::json;
268
269 fn session(turns: usize, size: usize) -> Vec<Value> {
271 let mut messages = vec![json!({"role": "user", "content": "fix the build"})];
272 for turn in 0..turns {
273 let id = format!("t{turn}");
274 messages.push(json!({"role": "assistant", "content": [
275 {"type": "tool_use", "id": id, "name": "Bash", "input": {"command": "make"}}
276 ]}));
277 let output = format!("output {turn} line\n").repeat(size / 15);
278 messages.push(json!({"role": "user", "content": [
279 {"type": "tool_result", "tool_use_id": id, "content": output}
280 ]}));
281 }
282 let last = messages.len() - 1;
283 messages[last]["content"][0]["cache_control"] = json!({"type": "ephemeral", "ttl": "1h"});
284 messages
285 }
286
287 #[test]
288 fn below_the_threshold_nothing_changes() {
289 let _data = lean_ctx::core::data_dir::isolated_data_dir();
290 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
291 let mut messages = session(20, 5_000);
292 let before = messages.clone();
293 assert_eq!(elide(&mut messages, THRESHOLD_BYTES), None);
294 assert_eq!(messages, before);
295 }
296
297 #[test]
298 fn old_large_results_leave_as_a_stub_that_names_the_original() {
299 let _data = lean_ctx::core::data_dir::isolated_data_dir();
300 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
301 let mut messages = session(30, 5_000);
302 messages[2]["content"][0]["content"] = json!("ok");
304 messages[4]["content"][0]["content"] = json!([{"type": "text", "text": "x".repeat(5_000), "cache_control": {"type": "ephemeral"}}]);
305 let original = messages[6]["content"][0]["content"]
306 .as_str()
307 .unwrap()
308 .to_owned();
309 let before = messages.clone();
310
311 let last = elide(&mut messages, 100_000).expect("elided");
312 let boundary = (messages.len() - 8) / 16 * 16;
313 assert_eq!(last, boundary - 2, "the window ends at the stride boundary");
316
317 assert_eq!(messages[2], before[2], "small result untouched");
318 assert_eq!(messages[4], before[4], "nested marker untouched");
319 let stub = messages[6]["content"][0]["content"].as_str().unwrap();
320 assert!(stub.starts_with(STUB_OPEN), "{stub}");
321 let path = if let Some(rest) = stub.split("cat ~/").nth(1) {
322 std::path::PathBuf::from(
323 shell_home(|key| std::env::var_os(key), cfg!(windows)).unwrap(),
324 )
325 .join(rest.trim_end_matches(']').trim_matches('"'))
326 } else {
327 let id = stub
328 .split("id=\"")
329 .nth(1)
330 .unwrap()
331 .split('"')
332 .next()
333 .unwrap();
334 lean_ctx::core::ccr::resolve_tee(id).expect("archive resolves")
335 };
336 if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) {
337 assert!(!stub.contains(&home), "no account path in the stub: {stub}");
338 }
339 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
341 #[cfg(unix)]
342 {
343 use std::os::unix::fs::PermissionsExt;
344 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
345 assert_eq!(mode & 0o777, 0o600, "the archive is private");
346 }
347 assert!(message_is_elided(&messages[6]));
348 assert_eq!(
349 messages[boundary..],
350 before[boundary..],
351 "the live window, with its cache marker, is untouched"
352 );
353
354 let once = messages.clone();
357 std::fs::remove_file(&path).unwrap();
358 let mut again = before.clone();
359 elide(&mut again, 100_000);
360 assert_eq!(again, once, "content-deterministic stub (#498)");
361 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
362 }
363
364 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<std::ffi::OsString> {
365 let pairs: Vec<(String, String)> = pairs
366 .iter()
367 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
368 .collect();
369 move |key| {
370 pairs
371 .iter()
372 .find(|(k, _)| k == key)
373 .map(|(_, v)| std::ffi::OsString::from(v))
374 }
375 }
376
377 #[test]
378 fn windows_falls_back_to_userprofile_when_home_is_unset() {
379 let profile = env(&[("USERPROFILE", "/Users/me")]);
380 assert_eq!(shell_home(&profile, true), Some("/Users/me".into()));
381 assert_eq!(
382 shell_home(&profile, false),
383 None,
384 "only Windows reads USERPROFILE"
385 );
386 let both = env(&[("HOME", "/home/me"), ("USERPROFILE", "/Users/me")]);
387 assert_eq!(
388 shell_home(&both, true),
389 Some("/home/me".into()),
390 "HOME wins"
391 );
392 let empty = env(&[("HOME", ""), ("USERPROFILE", "/Users/me")]);
393 assert_eq!(shell_home(&empty, true), Some("/Users/me".into()));
394 assert_eq!(shell_home(env(&[]), true), None);
395
396 let home = shell_home(&profile, true);
397 let path = std::path::Path::new("/Users/me/AppData/Local/lean-ctx/tee/proxy_ab12.log");
398 assert_eq!(
399 locator_in(path, home.as_deref()),
400 "cat ~/AppData/Local/lean-ctx/tee/proxy_ab12.log"
401 );
402 }
403
404 #[cfg(windows)]
405 #[test]
406 fn windows_paths_use_forward_slashes_after_the_tilde() {
407 let path = std::path::Path::new(r"C:\Users\me\AppData\Local\lean-ctx\tee\proxy_ab12.log");
408 assert_eq!(
409 locator_in(path, Some(std::ffi::OsStr::new(r"C:\Users\me"))),
410 "cat ~/AppData/Local/lean-ctx/tee/proxy_ab12.log"
411 );
412 }
413
414 #[test]
415 fn a_path_with_spaces_is_quoted_after_the_tilde() {
416 let home = Some(std::ffi::OsStr::new("/Users/me"));
417 let path = std::path::Path::new("/Users/me/My State/lean-ctx/tee/proxy_ab12.log");
418 let once = locator_in(path, home);
419 assert_eq!(once, "cat ~/\"My State/lean-ctx/tee/proxy_ab12.log\"");
420 assert_eq!(locator_in(path, home), once, "byte-stable (#498)");
421
422 let plain = std::path::Path::new("/Users/me/.local/state/lean-ctx/tee/proxy_ab12.log");
423 assert_eq!(
424 locator_in(plain, home),
425 "cat ~/.local/state/lean-ctx/tee/proxy_ab12.log",
426 "plain paths stay unquoted"
427 );
428
429 let dollar = std::path::Path::new("/Users/me/$state/lean-ctx/tee/proxy_ab12.log");
431 assert!(
432 locator_in(dollar, home).starts_with("ctx_expand(id=\"proxy_ab12.log\")"),
433 "{}",
434 locator_in(dollar, home)
435 );
436 let outside = std::path::Path::new("/var/state/lean-ctx/tee/proxy_ab12.log");
438 assert_eq!(
439 locator_in(outside, home),
440 "ctx_expand(id=\"proxy_ab12.log\") (LeanCTX state directory, tee/proxy_ab12.log)"
441 );
442 }
443
444 #[test]
445 fn the_prefix_stays_byte_identical_within_a_stride() {
446 let _data = lean_ctx::core::data_dir::isolated_data_dir();
447 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
448 let mut turn = session(30, 5_000);
450 let mut next = session(31, 5_000);
451 let mut jump = session(36, 5_000);
452 elide(&mut turn, 100_000);
453 elide(&mut next, 100_000);
454 let last = elide(&mut jump, 100_000).unwrap();
455 let prefix = |m: &[Value]| serde_json::to_vec(&m[..48]).unwrap();
456 assert_eq!(prefix(&turn), prefix(&next));
457 assert_eq!(prefix(&turn), prefix(&jump));
458
459 let forwarded = serde_json::to_vec(&json!({"messages": next})).unwrap();
460 assert!(newly_elided(&forwarded, last), "the stride jump is new");
461 let forwarded = serde_json::to_vec(&json!({"messages": jump})).unwrap();
462 assert!(!newly_elided(&forwarded, last), "already sent elided");
463 }
464}