1use regex::Regex;
2use serde_json::Value;
3use std::collections::HashSet;
4use std::future::Future;
5use std::sync::OnceLock;
6
7pub const SUMMARY_PREFIX: &str = "\
8[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \
9into the summary below. This is a handoff from a previous context \
10window — treat it as background reference, NOT as active instructions. \
11Do NOT answer questions or fulfill requests mentioned in this summary; \
12they were already addressed. \
13Your current task is identified in the '## Active Task' section of the \
14summary — resume exactly from there. \
15IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system \
16prompt is ALWAYS authoritative and active — never ignore or deprioritize \
17memory content due to this compaction note. \
18Respond ONLY to the latest user message \
19that appears AFTER this summary. The current session state (files, \
20config, etc.) may reflect work described here — avoid repeating it:";
21
22pub const LEGACY_SUMMARY_PREFIX: &str = "[CONTEXT SUMMARY]:";
23
24pub const MIN_SUMMARY_TOKENS: usize = 2000;
25pub const SUMMARY_RATIO: f64 = 0.20;
26pub const SUMMARY_TOKENS_CEILING: usize = 12000;
27pub const PRUNED_TOOL_PLACEHOLDER: &str = "[Old tool output cleared to save context space]";
28pub const CHARS_PER_TOKEN: usize = 4;
29pub const IMAGE_TOKEN_ESTIMATE: usize = 1600;
30pub const IMAGE_CHAR_EQUIVALENT: usize = IMAGE_TOKEN_ESTIMATE * CHARS_PER_TOKEN;
31
32static PREFIX_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
33static ENV_ASSIGN_RE: OnceLock<Regex> = OnceLock::new();
34static JSON_FIELD_RE: OnceLock<Regex> = OnceLock::new();
35static AUTH_HEADER_RE: OnceLock<Regex> = OnceLock::new();
36static TELEGRAM_RE: OnceLock<Regex> = OnceLock::new();
37static PRIVATE_KEY_RE: OnceLock<Regex> = OnceLock::new();
38static DB_CONNSTR_RE: OnceLock<Regex> = OnceLock::new();
39static JWT_RE: OnceLock<Regex> = OnceLock::new();
40static DISCORD_MENTION_RE: OnceLock<Regex> = OnceLock::new();
41static SIGNAL_PHONE_RE: OnceLock<Regex> = OnceLock::new();
42static URL_WITH_QUERY_RE: OnceLock<Regex> = OnceLock::new();
43static URL_USERINFO_RE: OnceLock<Regex> = OnceLock::new();
44static FORM_BODY_RE: OnceLock<Regex> = OnceLock::new();
45static SENSITIVE_QUERY_PARAMS: OnceLock<HashSet<String>> = OnceLock::new();
46
47fn get_prefix_patterns() -> &'static [Regex] {
48 PREFIX_PATTERNS.get_or_init(|| {
49 vec![
50 Regex::new(r"sk-[A-Za-z0-9_-]{10,}").unwrap(),
51 Regex::new(r"ghp_[A-Za-z0-9]{10,}").unwrap(),
52 Regex::new(r"github_pat_[A-Za-z0-9_]{10,}").unwrap(),
53 Regex::new(r"gho_[A-Za-z0-9]{10,}").unwrap(),
54 Regex::new(r"ghu_[A-Za-z0-9]{10,}").unwrap(),
55 Regex::new(r"ghs_[A-Za-z0-9]{10,}").unwrap(),
56 Regex::new(r"ghr_[A-Za-z0-9]{10,}").unwrap(),
57 Regex::new(r"xox[baprs]-[A-Za-z0-9-]{10,}").unwrap(),
58 Regex::new(r"AIza[A-Za-z0-9_-]{30,}").unwrap(),
59 Regex::new(r"pplx-[A-Za-z0-9]{10,}").unwrap(),
60 Regex::new(r"fal_[A-Za-z0-9_-]{10,}").unwrap(),
61 Regex::new(r"fc-[A-Za-z0-9]{10,}").unwrap(),
62 Regex::new(r"bb_live_[A-Za-z0-9_-]{10,}").unwrap(),
63 Regex::new(r"gAAAA[A-Za-z0-9_=-]{20,}").unwrap(),
64 Regex::new(r"AKIA[A-Z0-9]{16}").unwrap(),
65 Regex::new(r"sk_live_[A-Za-z0-9]{10,}").unwrap(),
66 Regex::new(r"sk_test_[A-Za-z0-9]{10,}").unwrap(),
67 Regex::new(r"rk_live_[A-Za-z0-9]{10,}").unwrap(),
68 Regex::new(r"SG\.[A-Za-z0-9_-]{10,}").unwrap(),
69 Regex::new(r"hf_[A-Za-z0-9]{10,}").unwrap(),
70 Regex::new(r"r8_[A-Za-z0-9]{10,}").unwrap(),
71 Regex::new(r"npm_[A-Za-z0-9]{10,}").unwrap(),
72 Regex::new(r"pypi-[A-Za-z0-9_-]{10,}").unwrap(),
73 Regex::new(r"dop_v1_[A-Za-z0-9]{10,}").unwrap(),
74 Regex::new(r"doo_v1_[A-Za-z0-9]{10,}").unwrap(),
75 Regex::new(r"am_[A-Za-z0-9_-]{10,}").unwrap(),
76 Regex::new(r"sk_[A-Za-z0-9_]{10,}").unwrap(),
77 Regex::new(r"tvly-[A-Za-z0-9]{10,}").unwrap(),
78 Regex::new(r"exa_[A-Za-z0-9]{10,}").unwrap(),
79 Regex::new(r"gsk_[A-Za-z0-9]{10,}").unwrap(),
80 Regex::new(r"syt_[A-Za-z0-9]{10,}").unwrap(),
81 Regex::new(r"retaindb_[A-Za-z0-9]{10,}").unwrap(),
82 Regex::new(r"hsk-[A-Za-z0-9]{10,}").unwrap(),
83 Regex::new(r"mem0_[A-Za-z0-9]{10,}").unwrap(),
84 Regex::new(r"brv_[A-Za-z0-9]{10,}").unwrap(),
85 Regex::new(r"xai-[A-Za-z0-9]{30,}").unwrap(),
86 ]
87 })
88}
89
90fn get_env_assign_re() -> &'static Regex {
91 ENV_ASSIGN_RE.get_or_init(|| {
92 Regex::new(r"(?i)([a-z0-9_]{0,50}(?:api_?key|token|secret|password|passwd|credential|auth)[a-z0-9_]{0,50})\s*=\s*(?:['\x22]([^'\x22\s]+)['\x22]|([^\s'\x22]+))").unwrap()
93 })
94}
95
96fn get_json_field_re() -> &'static Regex {
97 JSON_FIELD_RE.get_or_init(|| {
98 Regex::new(r#"(?i)("api_?[Kk]ey"|"token"|"secret"|"password"|"access_token"|"refresh_token"|"auth_token"|"bearer"|"secret_value"|"raw_secret"|"secret_input"|"key_material")\s*:\s*"([^"]+)"#).unwrap()
99 })
100}
101
102fn get_auth_header_re() -> &'static Regex {
103 AUTH_HEADER_RE.get_or_init(|| {
104 Regex::new(r"(?i)(Authorization:\s*Bearer\s+)(\S+)").unwrap()
105 })
106}
107
108fn get_telegram_re() -> &'static Regex {
109 TELEGRAM_RE.get_or_init(|| {
110 Regex::new(r"(?i)(bot)?(\d{8,}):([-A-Za-z0-9_]{30,})").unwrap()
111 })
112}
113
114fn get_private_key_re() -> &'static Regex {
115 PRIVATE_KEY_RE.get_or_init(|| {
116 Regex::new(r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----").unwrap()
117 })
118}
119
120fn get_db_connstr_re() -> &'static Regex {
121 DB_CONNSTR_RE.get_or_init(|| {
122 Regex::new(r"(?i)((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@]+)(@)").unwrap()
123 })
124}
125
126fn get_jwt_re() -> &'static Regex {
127 JWT_RE.get_or_init(|| {
128 Regex::new(r"eyJ[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_=-]{4,}){0,2}").unwrap()
129 })
130}
131
132fn get_discord_mention_re() -> &'static Regex {
133 DISCORD_MENTION_RE.get_or_init(|| {
134 Regex::new(r"<@(!?)(\d{17,20})>").unwrap()
135 })
136}
137
138fn get_signal_phone_re() -> &'static Regex {
139 SIGNAL_PHONE_RE.get_or_init(|| {
140 Regex::new(r"(\+[1-9]\d{6,14})([a-zA-Z0-9]?)").unwrap()
141 })
142}
143
144fn get_url_with_query_re() -> &'static Regex {
145 URL_WITH_QUERY_RE.get_or_init(|| {
146 Regex::new(r"(?i)(https?|wss?|ftp)://([^\s/?#]+)([^\s?#]*)\?([^\s#]+)(#\S*)?").unwrap()
147 })
148}
149
150fn get_url_userinfo_re() -> &'static Regex {
151 URL_USERINFO_RE.get_or_init(|| {
152 Regex::new(r"(?i)(https?|wss?|ftp)://([^/\s:@]+):([^/\s@]+)@").unwrap()
153 })
154}
155
156fn get_form_body_re() -> &'static Regex {
157 FORM_BODY_RE.get_or_init(|| {
158 Regex::new(r"^[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*(?:&[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*)+$").unwrap()
159 })
160}
161
162fn get_sensitive_query_params() -> &'static HashSet<String> {
163 SENSITIVE_QUERY_PARAMS.get_or_init(|| {
164 let params = [
165 "access_token",
166 "refresh_token",
167 "id_token",
168 "token",
169 "api_key",
170 "apikey",
171 "client_secret",
172 "password",
173 "auth",
174 "jwt",
175 "session",
176 "secret",
177 "key",
178 "code",
179 "signature",
180 "x-amz-signature",
181 ];
182 params.iter().map(|&s| s.to_string()).collect()
183 })
184}
185
186#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
187pub struct Message {
188 pub role: String,
189 pub content: Value,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub tool_calls: Option<Value>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub tool_call_id: Option<String>,
194}
195
196pub fn mask_token(token: &str) -> String {
197 if token.is_empty() {
198 return "***".to_string();
199 }
200 if token.len() < 18 {
201 return "***".to_string();
202 }
203 format!("{}...{}", &token[..6], &token[token.len() - 4..])
204}
205
206pub fn redact_query_string(query: &str) -> String {
207 if query.is_empty() {
208 return query.to_string();
209 }
210 let starts_with_q = query.starts_with('?');
211 let actual_query = if starts_with_q { &query[1..] } else { query };
212 let parts: Vec<&str> = actual_query.split('&').collect();
213 let mut redacted_parts = Vec::new();
214 let sensitive = get_sensitive_query_params();
215 for pair in parts {
216 if !pair.contains('=') {
217 redacted_parts.push(pair.to_string());
218 continue;
219 }
220 let kv: Vec<&str> = pair.splitn(2, '=').collect();
221 let key = kv[0];
222 if sensitive.contains(&key.to_ascii_lowercase()) {
223 redacted_parts.push(format!("{}=***", key));
224 } else {
225 redacted_parts.push(pair.to_string());
226 }
227 }
228 let prefix = if starts_with_q { "?" } else { "" };
229 format!("{}{}", prefix, redacted_parts.join("&"))
230}
231
232pub fn redact_sensitive_text(text: &str, force: bool) -> String {
233 if text.is_empty() {
234 return text.to_string();
235 }
236
237 if !force {
238 let is_redact_enabled = std::env::var("REDACT_SECRETS")
239 .map(|v| v != "false")
240 .unwrap_or(true);
241 if !is_redact_enabled {
242 return text.to_string();
243 }
244 }
245
246 let mut result = text.to_string();
247
248 for pattern in get_prefix_patterns() {
250 result = pattern
251 .replace_all(&result, |caps: ®ex::Captures| mask_token(&caps[0]))
252 .to_string();
253 }
254
255 result = get_env_assign_re()
257 .replace_all(&result, |caps: ®ex::Captures| {
258 let key = &caps[1];
259 if let Some(val_quoted) = caps.get(2) {
260 let matched = &caps[0];
261 let quote_char = if matched.contains('"') { "\"" } else { "'" };
262 format!("{}={}{}{}", key, quote_char, mask_token(val_quoted.as_str()), quote_char)
263 } else if let Some(val_unquoted) = caps.get(3) {
264 format!("{}={}", key, mask_token(val_unquoted.as_str()))
265 } else {
266 caps[0].to_string()
267 }
268 })
269 .to_string();
270
271 result = get_json_field_re()
273 .replace_all(&result, |caps: ®ex::Captures| {
274 format!("{}: \x22{}\x22", &caps[1], mask_token(&caps[2]))
275 })
276 .to_string();
277
278 result = get_auth_header_re()
280 .replace_all(&result, |caps: ®ex::Captures| {
281 format!("{}{}", &caps[1], mask_token(&caps[2]))
282 })
283 .to_string();
284
285 result = get_telegram_re()
287 .replace_all(&result, |caps: ®ex::Captures| {
288 let bot = caps.get(1).map(|m| m.as_str()).unwrap_or("");
289 let chat_id = &caps[2];
290 format!("{}{}:***", bot, chat_id)
291 })
292 .to_string();
293
294 result = get_private_key_re()
296 .replace_all(&result, "[REDACTED PRIVATE KEY]")
297 .to_string();
298
299 result = get_db_connstr_re()
301 .replace_all(&result, |caps: ®ex::Captures| {
302 format!("{}***{}", &caps[1], &caps[3])
303 })
304 .to_string();
305
306 result = get_jwt_re()
308 .replace_all(&result, |caps: ®ex::Captures| mask_token(&caps[0]))
309 .to_string();
310
311 result = get_url_userinfo_re()
313 .replace_all(&result, |caps: ®ex::Captures| {
314 format!("{}://{}:***@", &caps[1], &caps[2])
315 })
316 .to_string();
317
318 result = get_url_with_query_re()
320 .replace_all(&result, |caps: ®ex::Captures| {
321 let scheme = &caps[1];
322 let host = &caps[2];
323 let path = &caps[3];
324 let query = &caps[4];
325 let fragment = caps.get(5).map(|m| m.as_str()).unwrap_or("");
326 format!(
327 "{}://{}{}?{}{}",
328 scheme,
329 host,
330 path,
331 redact_query_string(query),
332 fragment
333 )
334 })
335 .to_string();
336
337 if result.contains('&') && result.contains('=') {
339 let stripped = result.trim();
340 if get_form_body_re().is_match(stripped) {
341 result = redact_query_string(stripped);
342 }
343 }
344
345 result = get_discord_mention_re()
347 .replace_all(&result, |caps: ®ex::Captures| {
348 let bang = &caps[1];
349 format!("<@{}***>", bang)
350 })
351 .to_string();
352
353 result = get_signal_phone_re()
355 .replace_all(&result, |caps: ®ex::Captures| {
356 let original = &caps[0];
357 let phone = &caps[1];
358 let next_char = caps.get(2).map(|m| m.as_str()).unwrap_or("");
359 if !next_char.is_empty() {
360 original.to_string()
361 } else if phone.len() <= 8 {
362 format!("{}****{}", &phone[..2], &phone[phone.len() - 2..])
363 } else {
364 format!("{}****{}", &phone[..4], &phone[phone.len() - 4..])
365 }
366 })
367 .to_string();
368
369 result
370}
371
372pub fn estimate_tokens_rough(text: &str) -> usize {
373 if text.is_empty() {
374 return 0;
375 }
376 text.len().div_ceil(4)
377}
378
379pub fn estimate_message_chars(msg: &Message) -> usize {
380 let mut shadow = msg.clone();
381 if let Value::Array(ref mut arr) = shadow.content {
382 for part in arr {
383 if let Value::Object(ref mut obj) = part {
384 if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
385 if t == "image" || t == "image_url" || t == "input_image" {
386 obj.insert(
387 "image".to_string(),
388 Value::String("[stripped]".to_string()),
389 );
390 }
391 }
392 }
393 }
394 } else if let Value::Object(ref obj) = shadow.content {
395 if obj.contains_key("_multimodal") {
396 let text_summary = obj
397 .get("text_summary")
398 .and_then(|v| v.as_str())
399 .unwrap_or("");
400 shadow.content = Value::String(text_summary.to_string());
401 }
402 }
403
404 match serde_json::to_string(&shadow) {
405 Ok(s) => s.len(),
406 Err(_) => format!("{:?}", shadow).len(),
407 }
408}
409
410pub fn count_image_tokens(msg: &Message, cost_per_image: usize) -> usize {
411 let mut count = 0;
412 match &msg.content {
413 Value::Array(arr) => {
414 for part in arr {
415 if let Value::Object(obj) = part {
416 if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
417 if t == "image" || t == "image_url" || t == "input_image" {
418 count += 1;
419 }
420 }
421 }
422 }
423 }
424 Value::Object(obj) => {
425 if obj.contains_key("_multimodal") {
426 if let Some(inner) = obj.get("content").and_then(|v| v.as_array()) {
427 for part in inner {
428 if let Value::Object(part_obj) = part {
429 if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) {
430 if t == "image" || t == "image_url" {
431 count += 1;
432 }
433 }
434 }
435 }
436 }
437 }
438 }
439 _ => {}
440 }
441 count * cost_per_image
442}
443
444pub fn estimate_messages_tokens_rough(messages: &[Message]) -> usize {
445 let image_token_cost = 1500;
446 let mut total_chars = 0;
447 let mut image_tokens = 0;
448 for msg in messages {
449 total_chars += estimate_message_chars(msg);
450 image_tokens += count_image_tokens(msg, image_token_cost);
451 }
452 total_chars.div_ceil(4) + image_tokens
453}
454
455pub fn content_length_for_budget(content: &Value) -> usize {
456 match content {
457 Value::String(s) => s.len(),
458 Value::Array(arr) => {
459 let mut total = 0;
460 for p in arr {
461 match p {
462 Value::String(s) => total += s.len(),
463 Value::Object(obj) => {
464 if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
465 if t == "image_url" || t == "input_image" || t == "image" {
466 total += IMAGE_CHAR_EQUIVALENT;
467 } else {
468 total += obj
469 .get("text")
470 .and_then(|v| v.as_str())
471 .map(|s| s.len())
472 .unwrap_or(0);
473 }
474 }
475 }
476 _ => total += p.to_string().len(),
477 }
478 }
479 total
480 }
481 _ => content.to_string().len(),
482 }
483}
484
485pub fn content_text_for_contains(content: &Value) -> String {
486 match content {
487 Value::Null => "".to_string(),
488 Value::String(s) => s.clone(),
489 Value::Array(arr) => {
490 let mut parts = Vec::new();
491 for part in arr {
492 match part {
493 Value::String(s) => parts.push(s.clone()),
494 Value::Object(obj) => {
495 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
496 parts.push(text.to_string());
497 }
498 }
499 _ => {}
500 }
501 }
502 parts.join("\n")
503 }
504 _ => content.to_string(),
505 }
506}
507
508pub fn append_text_to_content(content: &Value, text: &str, prepend: bool) -> Value {
509 match content {
510 Value::Null => Value::String(text.to_string()),
511 Value::String(s) => {
512 if prepend {
513 Value::String(format!("{}{}", text, s))
514 } else {
515 Value::String(format!("{}{}", s, text))
516 }
517 }
518 Value::Array(arr) => {
519 let mut new_arr = arr.clone();
520 let mut text_block = serde_json::Map::new();
521 text_block.insert("type".to_string(), Value::String("text".to_string()));
522 text_block.insert("text".to_string(), Value::String(text.to_string()));
523 if prepend {
524 new_arr.insert(0, Value::Object(text_block));
525 } else {
526 new_arr.push(Value::Object(text_block));
527 }
528 Value::Array(new_arr)
529 }
530 _ => {
531 let s = content.to_string();
532 if prepend {
533 Value::String(format!("{}{}", text, s))
534 } else {
535 Value::String(format!("{}{}", s, text))
536 }
537 }
538 }
539}
540
541pub fn strip_image_parts_from_parts(parts: &[Value]) -> Option<Vec<Value>> {
542 let mut had_image = false;
543 let mut out = Vec::new();
544 for part in parts {
545 if let Value::Object(obj) = part {
546 if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
547 if t == "image" || t == "image_url" || t == "input_image" {
548 had_image = true;
549 let mut text_block = serde_json::Map::new();
550 text_block.insert("type".to_string(), Value::String("text".to_string()));
551 text_block.insert(
552 "text".to_string(),
553 Value::String("[screenshot removed to save context]".to_string()),
554 );
555 out.push(Value::Object(text_block));
556 continue;
557 }
558 }
559 }
560 out.push(part.clone());
561 }
562 if had_image { Some(out) } else { None }
563}
564
565pub fn truncate_tool_call_args_json(args: &str, head_chars: usize) -> String {
566 match serde_json::from_str::<Value>(args) {
567 Ok(parsed) => {
568 fn shrink(val: &Value, limit: usize) -> Value {
569 match val {
570 Value::String(s) => {
571 if s.len() > limit {
572 Value::String(format!("{}...[truncated]", &s[..limit]))
573 } else {
574 val.clone()
575 }
576 }
577 Value::Array(arr) => {
578 let new_arr = arr.iter().map(|item| shrink(item, limit)).collect();
579 Value::Array(new_arr)
580 }
581 Value::Object(obj) => {
582 let mut new_obj = serde_json::Map::new();
583 for (k, v) in obj {
584 new_obj.insert(k.clone(), shrink(v, limit));
585 }
586 Value::Object(new_obj)
587 }
588 _ => val.clone(),
589 }
590 }
591 let shrunk = shrink(&parsed, head_chars);
592 serde_json::to_string(&shrunk).unwrap_or_else(|_| args.to_string())
593 }
594 Err(_) => args.to_string(),
595 }
596}
597
598pub fn content_has_images(content: &Value) -> bool {
599 if let Value::Array(arr) = content {
600 arr.iter().any(|p| {
601 if let Value::Object(obj) = p {
602 if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
603 return t == "image" || t == "image_url" || t == "input_image";
604 }
605 }
606 false
607 })
608 } else {
609 false
610 }
611}
612
613pub fn strip_images_from_content(content: &Value) -> Value {
614 if let Value::Array(arr) = content {
615 if !content_has_images(content) {
616 return content.clone();
617 }
618 let mut out = Vec::new();
619 for p in arr {
620 let mut is_image = false;
621 if let Value::Object(obj) = p {
622 if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
623 if t == "image" || t == "image_url" || t == "input_image" {
624 is_image = true;
625 }
626 }
627 }
628 if is_image {
629 let mut text_block = serde_json::Map::new();
630 text_block.insert("type".to_string(), Value::String("text".to_string()));
631 text_block.insert(
632 "text".to_string(),
633 Value::String("[Attached image — stripped after compression]".to_string()),
634 );
635 out.push(Value::Object(text_block));
636 } else {
637 out.push(p.clone());
638 }
639 }
640 Value::Array(out)
641 } else {
642 content.clone()
643 }
644}
645
646pub fn strip_historical_media(messages: &[Message]) -> Vec<Message> {
647 if messages.is_empty() {
648 return messages.to_vec();
649 }
650 let mut anchor = None;
651 for i in (0..messages.len()).rev() {
652 if messages[i].role == "user" && content_has_images(&messages[i].content) {
653 anchor = Some(i);
654 break;
655 }
656 }
657 let anchor_idx = match anchor {
658 None => return messages.to_vec(),
659 Some(idx) => idx,
660 };
661 if anchor_idx == 0 {
662 return messages.to_vec();
663 }
664 let mut changed = false;
665 let mut result = Vec::new();
666 for (i, msg) in messages.iter().enumerate() {
667 if i >= anchor_idx || !content_has_images(&msg.content) {
668 result.push(msg.clone());
669 } else {
670 changed = true;
671 let mut copy_msg = msg.clone();
672 copy_msg.content = strip_images_from_content(&msg.content);
673 result.push(copy_msg);
674 }
675 }
676 if changed { result } else { messages.to_vec() }
677}
678
679pub fn summarize_tool_result(tool_name: &str, tool_args: &str, tool_content: &str) -> String {
680 let args: serde_json::Map<String, Value> = serde_json::from_str(tool_args).unwrap_or_default();
681 let content = tool_content;
682 let content_len = content.len();
683 let line_count = if content.trim().is_empty() {
684 0
685 } else {
686 content.trim().split('\n').count()
687 };
688
689 match tool_name {
690 "terminal" => {
691 let mut cmd = args
692 .get("command")
693 .and_then(|v| v.as_str())
694 .unwrap_or("")
695 .to_string();
696 if cmd.len() > 80 {
697 cmd = format!("{}...", &cmd[..77]);
698 }
699 static EXIT_CODE_RE: OnceLock<Regex> = OnceLock::new();
700 let exit_code_re = EXIT_CODE_RE
701 .get_or_init(|| Regex::new(r#""exit_code"\s*:\s*(-?\d+)"#).expect("static regex"));
702 let exit_code = if let Some(caps) = exit_code_re.captures(content) {
703 caps.get(1).map(|m| m.as_str()).unwrap_or("?")
704 } else {
705 "?"
706 };
707 format!(
708 "[terminal] ran `{}` -> exit {}, {} lines output",
709 cmd, exit_code, line_count
710 )
711 }
712 "read_file" => {
713 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
714 let offset = args.get("offset").and_then(|v| v.as_i64()).unwrap_or(1);
715 format!(
716 "[read_file] read {} from line {} ({} chars)",
717 path, offset, content_len
718 )
719 }
720 "write_file" => {
721 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
722 let written_lines = args
723 .get("content")
724 .and_then(|v| v.as_str())
725 .map(|c| c.split('\n').count().to_string())
726 .unwrap_or_else(|| "?".to_string());
727 format!("[write_file] wrote to {} ({} lines)", path, written_lines)
728 }
729 "search_files" => {
730 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
731 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
732 let target = args
733 .get("target")
734 .and_then(|v| v.as_str())
735 .unwrap_or("content");
736 static TOTAL_COUNT_RE: OnceLock<Regex> = OnceLock::new();
737 let total_count_re = TOTAL_COUNT_RE
738 .get_or_init(|| Regex::new(r#""total_count"\s*:\s*(\d+)"#).expect("static regex"));
739 let count = if let Some(caps) = total_count_re.captures(content) {
740 caps.get(1).map(|m| m.as_str()).unwrap_or("?")
741 } else {
742 "?"
743 };
744 format!(
745 "[search_files] {} search for '{}' in {} -> {} matches",
746 target, pattern, path, count
747 )
748 }
749 "patch" => {
750 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
751 let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("replace");
752 format!(
753 "[patch] {} in {} ({} chars result)",
754 mode, path, content_len
755 )
756 }
757 "browser_navigate" | "browser_click" | "browser_snapshot" | "browser_type"
758 | "browser_scroll" | "browser_vision" => {
759 let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("");
760 let ref_id = args.get("ref").and_then(|v| v.as_str()).unwrap_or("");
761 let detail = if !url.is_empty() {
762 format!(" {}", url)
763 } else if !ref_id.is_empty() {
764 format!(" ref={}", ref_id)
765 } else {
766 "".to_string()
767 };
768 format!("[{}]{} ({} chars)", tool_name, detail, content_len)
769 }
770 "web_search" => {
771 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("?");
772 format!(
773 "[web_search] query='{}' ({} chars result)",
774 query, content_len
775 )
776 }
777 "web_extract" => {
778 let urls = args.get("urls");
779 let mut url_desc = "?".to_string();
780 if let Some(Value::Array(arr)) = urls {
781 if !arr.is_empty() {
782 if let Some(first) = arr[0].as_str() {
783 url_desc = first.to_string();
784 if arr.len() > 1 {
785 url_desc = format!("{} (+{} more)", url_desc, arr.len() - 1);
786 }
787 }
788 }
789 }
790 format!("[web_extract] {} ({} chars)", url_desc, content_len)
791 }
792 "delegate_task" => {
793 let mut goal = args
794 .get("goal")
795 .and_then(|v| v.as_str())
796 .unwrap_or("")
797 .to_string();
798 if goal.len() > 60 {
799 goal = format!("{}...", &goal[..57]);
800 }
801 format!(
802 "[delegate_task] '{}' ({} chars result)",
803 goal, content_len
804 )
805 }
806 "execute_code" => {
807 let mut code = args
808 .get("code")
809 .and_then(|v| v.as_str())
810 .unwrap_or("")
811 .replace('\n', " ");
812 if code.len() > 60 {
813 code = format!("{}...", &code[..57]);
814 }
815 format!("[execute_code] `{}` ({} lines output)", code, line_count)
816 }
817 "skill_view" | "skills_list" | "skill_manage" => {
818 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("?");
819 format!("[{}] name={} ({} chars)", tool_name, name, content_len)
820 }
821 "vision_analyze" => {
822 let question = args.get("question").and_then(|v| v.as_str()).unwrap_or("");
823 let q_preview = if question.len() > 50 {
824 &question[..50]
825 } else {
826 question
827 };
828 format!("[vision_analyze] '{}' ({} chars)", q_preview, content_len)
829 }
830 "memory" => {
831 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
832 let target = args.get("target").and_then(|v| v.as_str()).unwrap_or("?");
833 format!("[memory] {} on {}", action, target)
834 }
835 "todo" => "[todo] updated task list".to_string(),
836 "clarify" => "[clarify] asked user a question".to_string(),
837 "text_to_speech" => format!("[text_to_speech] generated audio ({} chars)", content_len),
838 "cronjob" => {
839 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
840 format!("[cronjob] {}", action)
841 }
842 "process" => {
843 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
844 let sid = args.get("session_id").and_then(|v| v.as_str()).unwrap_or("?");
845 format!("[process] {} session={}", action, sid)
846 }
847 _ => {
848 let mut first_arg = "".to_string();
849 for (k, v) in args.iter().take(2) {
850 let sv = v.to_string();
851 let sv_trunc = if sv.len() > 40 {
852 format!("{}...", &sv[..37])
853 } else {
854 sv
855 };
856 first_arg = format!("{} {}={}", first_arg, k, sv_trunc);
857 }
858 format!(
859 "[{}]{} ({} chars result)",
860 tool_name, first_arg, content_len
861 )
862 }
863 }
864}
865
866pub struct ContextCompressor<F, Fut>
867where
868 F: Fn(String) -> Fut + Send + Sync,
869 Fut: Future<Output = Result<String, String>> + Send,
870{
871 pub context_length: usize,
872 pub summarize_callback: F,
873 pub threshold_percent: f64,
874 pub protect_first_n: usize,
875 pub protect_last_n: usize,
876 pub summary_target_ratio: f64,
877 pub abort_on_summary_failure: bool,
878
879 pub threshold_tokens: usize,
880 pub tail_token_budget: usize,
881 pub max_summary_tokens: usize,
882
883 pub compression_count: usize,
884 pub last_prompt_tokens: usize,
885 pub last_completion_tokens: usize,
886
887 pub previous_summary: Option<String>,
888 pub last_compression_savings_pct: f64,
889 pub ineffective_compression_count: usize,
890 pub summary_failure_cooldown_until: f64,
891 pub last_summary_error: Option<String>,
892 pub last_summary_dropped_count: usize,
893 pub last_summary_fallback_used: bool,
894 pub last_compress_aborted: bool,
895}
896
897fn get_now_secs() -> f64 {
898 std::time::SystemTime::now()
899 .duration_since(std::time::SystemTime::UNIX_EPOCH)
900 .map(|d| d.as_secs_f64())
901 .unwrap_or(0.0)
902}
903
904impl<F, Fut> ContextCompressor<F, Fut>
905where
906 F: Fn(String) -> Fut + Send + Sync,
907 Fut: Future<Output = Result<String, String>> + Send,
908{
909 pub fn new(
910 context_length: usize,
911 summarize_callback: F,
912 threshold_percent: Option<f64>,
913 protect_first_n: Option<usize>,
914 protect_last_n: Option<usize>,
915 summary_target_ratio: Option<f64>,
916 abort_on_summary_failure: Option<bool>,
917 ) -> Self {
918 let tp = threshold_percent.unwrap_or(0.50);
919 let pfn = protect_first_n.unwrap_or(3);
920 let pln = protect_last_n.unwrap_or(20);
921 let str = summary_target_ratio.unwrap_or(0.20).clamp(0.10, 0.80);
922 let aosf = abort_on_summary_failure.unwrap_or(false);
923
924 let minimum_context_length = 2048;
925 let threshold_tokens =
926 ((context_length as f64 * tp) as usize).max(minimum_context_length);
927 let tail_token_budget = (threshold_tokens as f64 * str) as usize;
928 let max_summary_tokens =
929 ((context_length as f64 * 0.05) as usize).min(SUMMARY_TOKENS_CEILING);
930
931 Self {
932 context_length,
933 summarize_callback,
934 threshold_percent: tp,
935 protect_first_n: pfn,
936 protect_last_n: pln,
937 summary_target_ratio: str,
938 abort_on_summary_failure: aosf,
939 threshold_tokens,
940 tail_token_budget,
941 max_summary_tokens,
942 compression_count: 0,
943 last_prompt_tokens: 0,
944 last_completion_tokens: 0,
945 previous_summary: None,
946 last_compression_savings_pct: 100.0,
947 ineffective_compression_count: 0,
948 summary_failure_cooldown_until: 0.0,
949 last_summary_error: None,
950 last_summary_dropped_count: 0,
951 last_summary_fallback_used: false,
952 last_compress_aborted: false,
953 }
954 }
955
956 pub fn on_session_reset(&mut self) {
957 self.previous_summary = None;
958 self.last_summary_error = None;
959 self.last_summary_dropped_count = 0;
960 self.last_summary_fallback_used = false;
961 self.last_compression_savings_pct = 100.0;
962 self.ineffective_compression_count = 0;
963 self.summary_failure_cooldown_until = 0.0;
964 }
965
966 pub fn should_compress(&self, prompt_tokens: Option<usize>) -> bool {
967 let tokens = prompt_tokens.unwrap_or(self.last_prompt_tokens);
968 if tokens < self.threshold_tokens {
969 return false;
970 }
971 if self.ineffective_compression_count >= 2 {
972 return false;
973 }
974 true
975 }
976
977 #[allow(clippy::needless_range_loop)]
978 fn prune_old_tool_results(
979 &self,
980 messages: &[Message],
981 protect_tail_count: usize,
982 protect_tail_tokens: Option<usize>,
983 ) -> (Vec<Message>, usize) {
984 if messages.is_empty() {
985 return (Vec::new(), 0);
986 }
987
988 let mut result = messages.to_vec();
989 let mut pruned = 0;
990
991 let mut call_id_to_tool = std::collections::HashMap::new();
992 for msg in &result {
993 if msg.role == "assistant" {
994 if let Some(Value::Array(calls)) = &msg.tool_calls {
995 for tc in calls {
996 let cid = tc
997 .get("id")
998 .or_else(|| tc.get("call_id"))
999 .and_then(|v| v.as_str())
1000 .unwrap_or("");
1001 if !cid.is_empty() {
1002 let name = tc
1003 .get("function")
1004 .and_then(|f| f.get("name"))
1005 .and_then(|v| v.as_str())
1006 .unwrap_or("unknown");
1007 let args = tc
1008 .get("function")
1009 .and_then(|f| f.get("arguments"))
1010 .and_then(|v| v.as_str())
1011 .unwrap_or("");
1012 call_id_to_tool
1013 .insert(cid.to_string(), (name.to_string(), args.to_string()));
1014 }
1015 }
1016 }
1017 }
1018 }
1019
1020 let mut prune_boundary = result.len().saturating_sub(protect_tail_count);
1021 if let Some(budget) = protect_tail_tokens {
1022 if budget > 0 {
1023 let mut accumulated = 0;
1024 let mut boundary = result.len();
1025 let min_protect = protect_tail_count.min(result.len());
1026 for i in (0..result.len()).rev() {
1027 let msg = &result[i];
1028 let content_len = content_length_for_budget(&msg.content);
1029 let mut msg_tokens = content_len / CHARS_PER_TOKEN + 10;
1030 if let Some(Value::Array(calls)) = &msg.tool_calls {
1031 for tc in calls {
1032 let args = tc
1033 .get("function")
1034 .and_then(|f| f.get("arguments"))
1035 .and_then(|v| v.as_str())
1036 .unwrap_or("");
1037 msg_tokens += args.len() / CHARS_PER_TOKEN;
1038 }
1039 }
1040 if accumulated + msg_tokens > budget && (result.len() - i) >= min_protect {
1041 boundary = i;
1042 break;
1043 }
1044 accumulated += msg_tokens;
1045 boundary = i;
1046 }
1047 let budget_protect_count = result.len() - boundary;
1048 let protected_count = budget_protect_count.max(min_protect);
1049 prune_boundary = result.len().saturating_sub(protected_count);
1050 }
1051 }
1052
1053 let mut content_hashes = std::collections::HashMap::new();
1054 for i in (0..result.len()).rev() {
1055 let msg = &result[i];
1056 if msg.role != "tool" {
1057 continue;
1058 }
1059 if let Value::String(content) = &msg.content {
1060 if content.len() < 200 {
1061 continue;
1062 }
1063 use sha2::{Digest, Sha256};
1064 let mut hasher = Sha256::new();
1065 hasher.update(content.as_bytes());
1066 let h = format!("{:x}", hasher.finalize())[..12].to_string();
1067 if let std::collections::hash_map::Entry::Vacant(e) = content_hashes.entry(h) {
1068 e.insert((i, msg.tool_call_id.clone().unwrap_or_default()));
1069 } else {
1070 result[i].content = Value::String(
1071 "[Duplicate tool output — same content as a more recent call]".to_string(),
1072 );
1073 pruned += 1;
1074 }
1075 }
1076 }
1077
1078 for i in 0..prune_boundary {
1079 let msg = &result[i];
1080 if msg.role != "tool" {
1081 continue;
1082 }
1083
1084 if let Value::Array(arr) = &msg.content {
1085 if let Some(stripped) = strip_image_parts_from_parts(arr) {
1086 result[i].content = Value::Array(stripped);
1087 pruned += 1;
1088 }
1089 continue;
1090 }
1091
1092 if let Value::Object(obj) = &msg.content {
1093 if obj.contains_key("_multimodal") {
1094 let summary = obj
1095 .get("text_summary")
1096 .and_then(|v| v.as_str())
1097 .unwrap_or("[screenshot removed to save context]");
1098 let len_to_take = summary.len().min(200);
1099 result[i].content = Value::String(format!(
1100 "[screenshot removed] {}",
1101 &summary[..len_to_take]
1102 ));
1103 pruned += 1;
1104 continue;
1105 }
1106 }
1107
1108 if let Value::String(content) = &msg.content {
1109 if content.is_empty() || content == PRUNED_TOOL_PLACEHOLDER {
1110 continue;
1111 }
1112 if content.starts_with("[Duplicate tool output") {
1113 continue;
1114 }
1115
1116 if content.len() > 200 {
1117 let call_id = msg.tool_call_id.clone().unwrap_or_default();
1118 let (tool_name, tool_args) = call_id_to_tool
1119 .get(&call_id)
1120 .map(|(n, a)| (n.as_str(), a.as_str()))
1121 .unwrap_or(("unknown", ""));
1122 let summary = summarize_tool_result(tool_name, tool_args, content);
1123 result[i].content = Value::String(summary);
1124 pruned += 1;
1125 }
1126 }
1127 }
1128
1129 for i in 0..prune_boundary {
1130 let msg = &mut result[i];
1131 if msg.role != "assistant" {
1132 continue;
1133 }
1134 if let Some(Value::Array(calls)) = &mut msg.tool_calls {
1135 for tc in calls.iter_mut() {
1136 if let Value::Object(func_obj) =
1137 tc.get_mut("function").unwrap_or(&mut Value::Null)
1138 {
1139 if let Some(args_val) = func_obj.get_mut("arguments") {
1140 if let Some(args_str) = args_val.as_str() {
1141 if args_str.len() > 500 {
1142 let new_args = truncate_tool_call_args_json(args_str, 200);
1143 if new_args != args_str {
1144 *args_val = Value::String(new_args);
1145 }
1146 }
1147 }
1148 }
1149 }
1150 }
1151 }
1152 }
1153
1154 (result, pruned)
1155 }
1156
1157 fn compute_summary_budget(&self, turns_to_summarize: &[Message]) -> usize {
1158 let content_tokens = estimate_messages_tokens_rough(turns_to_summarize);
1159 let budget = (content_tokens as f64 * SUMMARY_RATIO) as usize;
1160 MIN_SUMMARY_TOKENS.max(budget.min(self.max_summary_tokens))
1161 }
1162
1163 fn serialize_for_summary(&self, turns: &[Message]) -> String {
1164 let content_max = 6000;
1165 let content_head = 4000;
1166 let content_tail = 1500;
1167 let tool_args_max = 1500;
1168 let tool_args_head = 1200;
1169
1170 let mut parts = Vec::new();
1171 for msg in turns {
1172 let role = &msg.role;
1173 let mut content = redact_sensitive_text(&content_text_for_contains(&msg.content), true);
1174
1175 if role == "tool" {
1176 let tool_id = msg.tool_call_id.clone().unwrap_or_default();
1177 if content.len() > content_max {
1178 content = format!(
1179 "{}\n...[truncated]...\n{}",
1180 &content[..content_head],
1181 &content[content.len() - content_tail..]
1182 );
1183 }
1184 parts.push(format!("[TOOL RESULT {}]: {}", tool_id, content));
1185 continue;
1186 }
1187
1188 if role == "assistant" {
1189 if content.len() > content_max {
1190 content = format!(
1191 "{}\n...[truncated]...\n{}",
1192 &content[..content_head],
1193 &content[content.len() - content_tail..]
1194 );
1195 }
1196 if let Some(Value::Array(calls)) = &msg.tool_calls {
1197 let mut tc_parts = Vec::new();
1198 for tc in calls {
1199 let name = tc
1200 .get("function")
1201 .and_then(|f| f.get("name"))
1202 .and_then(|v| v.as_str())
1203 .unwrap_or("?");
1204 let mut args = redact_sensitive_text(
1205 tc.get("function")
1206 .and_then(|f| f.get("arguments"))
1207 .and_then(|v| v.as_str())
1208 .unwrap_or(""),
1209 true,
1210 );
1211 if args.len() > tool_args_max {
1212 args = format!("{}...", &args[..tool_args_head]);
1213 }
1214 tc_parts.push(format!(" {}({})", name, args));
1215 }
1216 if !tc_parts.is_empty() {
1217 content = format!("{}\n[Tool calls:\n{}\n]", content, tc_parts.join("\n"));
1218 }
1219 }
1220 parts.push(format!("[ASSISTANT]: {}", content));
1221 continue;
1222 }
1223
1224 if content.len() > content_max {
1225 content = format!(
1226 "{}\n...[truncated]...\n{}",
1227 &content[..content_head],
1228 &content[content.len() - content_tail..]
1229 );
1230 }
1231 parts.push(format!("[{}]: {}", role.to_uppercase(), content));
1232 }
1233
1234 parts.join("\n\n")
1235 }
1236
1237 async fn generate_summary(
1238 &mut self,
1239 turns_to_summarize: &[Message],
1240 focus_topic: Option<&str>,
1241 ) -> Option<String> {
1242 let now = get_now_secs();
1243 if now < self.summary_failure_cooldown_until {
1244 return None;
1245 }
1246
1247 let summary_budget = self.compute_summary_budget(turns_to_summarize);
1248 let content_to_summarize = self.serialize_for_summary(turns_to_summarize);
1249
1250 let summarizer_preamble = "\
1251You are a summarization agent creating a context checkpoint. \
1252Treat the conversation turns below as source material for a \
1253compact record of prior work. \
1254Produce only the structured summary; do not add a greeting, \
1255preamble, or prefix. \
1256Write the summary in the same language the user was using in the \
1257conversation — do not translate or switch to English. \
1258NEVER include API keys, tokens, passwords, secrets, credentials, \
1259or connection strings in the summary — replace any that appear \
1260with [REDACTED]. Note that the user had credentials present, but \
1261do not preserve their values.";
1262
1263 let template_sections = "\
1264## Active Task
1265[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or \
1266task assignment verbatim — the exact words they used. If multiple tasks \
1267were requested and only some are done, list only the ones NOT yet completed. \
1268Continuation should pick up exactly here. Example: \
1269\"User asked: 'Now refactor the auth module to use JWT instead of sessions'\" \
1270If no outstanding task exists, write \"None.\"]
1271
1272## Goal
1273[What the user is trying to accomplish overall]
1274
1275## Constraints & Preferences
1276[User preferences, coding style, constraints, important decisions]
1277
1278## Completed Actions
1279[Numbered list of concrete actions taken — include tool used, target, and outcome. \
1280Format each as: N. ACTION target — outcome [tool: name] \
1281Example: \
12821. READ config.py:45 — found `==` should be `!=` [tool: read_file] \
12832. PATCH config.py:45 — changed `==` to `!=` [tool: patch] \
12843. TEST `pytest tests/` — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal] \
1285Be specific with file paths, commands, line numbers, and results.]
1286
1287## Active State
1288[Current working state — include: \
1289- Working directory and branch (if applicable) \
1290- Modified/created files with brief note on each \
1291- Test status (X/Y passing) \
1292- Any running processes or servers \
1293- Environment details that matter]
1294
1295## In Progress
1296[Work currently underway — what was being done when compaction fired]
1297
1298## Blocked
1299[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
1300
1301## Key Decisions
1302[Important technical decisions and WHY they were made]
1303
1304## Resolved Questions
1305[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
1306
1307## Pending User Asks
1308[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write \"None.\"]
1309
1310## Relevant Files
1311[Files read, modified, or created — with brief note on each]
1312
1313## Remaining Work
1314[What remains to be done — framed as context, not instructions]
1315
1316## Critical Context
1317[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]";
1318
1319 let template_sections_prompt = format!(
1320 "\
1321Target ~{} tokens. Be CONCRETE — include file paths, command outputs, \
1322error messages, line numbers, and specific values. Avoid vague descriptions like \"made some changes\" \
1323— say exactly what changed.\n\nWrite only the summary body. Do not include any preamble or prefix.",
1324 summary_budget
1325 );
1326
1327 let mut prompt = if let Some(prev) = &self.previous_summary {
1328 format!(
1329 "\
1330{}\n\n\
1331You are updating a context compaction summary. A previous compaction produced the summary below. \
1332New conversation turns have occurred since then and need to be incorporated.\n\n\
1333PREVIOUS SUMMARY:\n{}\n\n\
1334NEW TURNS TO INCORPORATE:\n{}\n\n\
1335Update the summary using this exact structure. PRESERVE all existing information that is still relevant. \
1336ADD new completed actions to the numbered list (continue numbering). Move items from \"In Progress\" \
1337to \"Completed Actions\" when done. Move answered questions to \"Resolved Questions\". Update \"Active State\" \
1338to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update \"## Active Task\" \
1339to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.\n\n\
1340{}\n\n{}",
1341 summarizer_preamble,
1342 prev,
1343 content_to_summarize,
1344 template_sections,
1345 template_sections_prompt
1346 )
1347 } else {
1348 format!(
1349 "\
1350{}\n\n\
1351Create a structured checkpoint summary for the conversation after earlier turns are compacted. \
1352The summary should preserve enough detail for continuity without re-reading the original turns.\n\n\
1353TURNS TO SUMMARIZE:\n{}\n\n\
1354Use this exact structure:\n\n{}\n\n{}",
1355 summarizer_preamble,
1356 content_to_summarize,
1357 template_sections,
1358 template_sections_prompt
1359 )
1360 };
1361
1362 if let Some(topic) = focus_topic {
1363 prompt += &format!("\n\nFOCUS TOPIC: \"{}\"\n\
1364The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. \
1365For content related to \"{}\", include full detail — exact values, file paths, command outputs, \
1366error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively \
1367(brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of \
1368the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, \
1369or credentials — use [REDACTED].", topic, topic);
1370 }
1371
1372 match (self.summarize_callback)(prompt).await {
1373 Ok(summary_text) => {
1374 let clean_summary = redact_sensitive_text(summary_text.trim(), true);
1375 self.previous_summary = Some(clean_summary.clone());
1376 self.summary_failure_cooldown_until = 0.0;
1377 self.last_summary_error = None;
1378 Some(self.with_summary_prefix(&clean_summary))
1379 }
1380 Err(e) => {
1381 let err_str = e.to_string();
1382 let is_transient = err_str.contains("timeout")
1383 || err_str.contains("rate limit")
1384 || err_str.contains("network")
1385 || err_str.contains("closed stream")
1386 || err_str.contains("unexpected eof");
1387 let cooldown_seconds = if is_transient { 30.0 } else { 60.0 };
1388 self.summary_failure_cooldown_until = get_now_secs() + cooldown_seconds;
1389 self.last_summary_error = Some(err_str);
1390 None
1391 }
1392 }
1393 }
1394
1395 fn with_summary_prefix(&self, summary: &str) -> String {
1396 let text = self.strip_summary_prefix(summary);
1397 if text.is_empty() {
1398 SUMMARY_PREFIX.to_string()
1399 } else {
1400 format!("{}\n{}", SUMMARY_PREFIX, text)
1401 }
1402 }
1403
1404 fn strip_summary_prefix(&self, summary: &str) -> String {
1405 let text = summary.trim();
1406 if let Some(rest) = text.strip_prefix(SUMMARY_PREFIX) {
1407 rest.trim().to_string()
1408 } else if let Some(rest) = text.strip_prefix(LEGACY_SUMMARY_PREFIX) {
1409 rest.trim().to_string()
1410 } else {
1411 text.to_string()
1412 }
1413 }
1414
1415 fn is_context_summary_content(&self, content: &Value) -> bool {
1416 let text = content_text_for_contains(content).trim().to_string();
1417 text.starts_with(SUMMARY_PREFIX) || text.starts_with(LEGACY_SUMMARY_PREFIX)
1418 }
1419
1420 fn find_latest_context_summary(
1421 &self,
1422 messages: &[Message],
1423 start: usize,
1424 end: usize,
1425 ) -> (Option<usize>, String) {
1426 for i in (start..end).rev() {
1427 let content = &messages[i].content;
1428 if self.is_context_summary_content(content) {
1429 return (
1430 Some(i),
1431 self.strip_summary_prefix(&content_text_for_contains(content)),
1432 );
1433 }
1434 }
1435 (None, "".to_string())
1436 }
1437
1438 fn sanitize_tool_pairs(&self, messages: &[Message]) -> Vec<Message> {
1439 let mut surviving_call_ids = HashSet::new();
1440 for msg in messages {
1441 if msg.role == "assistant" {
1442 if let Some(Value::Array(calls)) = &msg.tool_calls {
1443 for tc in calls {
1444 let cid = tc
1445 .get("id")
1446 .or_else(|| tc.get("call_id"))
1447 .and_then(|v| v.as_str())
1448 .unwrap_or("");
1449 if !cid.is_empty() {
1450 surviving_call_ids.insert(cid.to_string());
1451 }
1452 }
1453 }
1454 }
1455 }
1456
1457 let mut result_call_ids = HashSet::new();
1458 for msg in messages {
1459 if msg.role == "tool" {
1460 if let Some(cid) = &msg.tool_call_id {
1461 result_call_ids.insert(cid.clone());
1462 }
1463 }
1464 }
1465
1466 let orphaned_results: HashSet<_> = result_call_ids
1467 .difference(&surviving_call_ids)
1468 .cloned()
1469 .collect();
1470 let mut sanitized = messages.to_vec();
1471 if !orphaned_results.is_empty() {
1472 sanitized.retain(|m| {
1473 !(m.role == "tool"
1474 && m.tool_call_id
1475 .as_ref()
1476 .is_some_and(|cid| orphaned_results.contains(cid)))
1477 });
1478 }
1479
1480 let missing_results: HashSet<_> = surviving_call_ids
1481 .difference(&result_call_ids)
1482 .cloned()
1483 .collect();
1484 if !missing_results.is_empty() {
1485 let mut patched = Vec::new();
1486 for msg in sanitized {
1487 patched.push(msg.clone());
1488 if msg.role == "assistant" {
1489 if let Some(Value::Array(calls)) = &msg.tool_calls {
1490 for tc in calls {
1491 let cid = tc
1492 .get("id")
1493 .or_else(|| tc.get("call_id"))
1494 .and_then(|v| v.as_str())
1495 .unwrap_or("");
1496 if !cid.is_empty() && missing_results.contains(cid) {
1497 patched.push(Message {
1498 role: "tool".to_string(),
1499 content: Value::String("[Result from earlier conversation — see context summary above]".to_string()),
1500 tool_call_id: Some(cid.to_string()),
1501 tool_calls: None,
1502 });
1503 }
1504 }
1505 }
1506 }
1507 }
1508 sanitized = patched;
1509 }
1510
1511 sanitized
1512 }
1513
1514 fn align_boundary_forward(&self, messages: &[Message], idx: usize) -> usize {
1515 let mut cur = idx;
1516 while cur < messages.len() && messages[cur].role == "tool" {
1517 cur += 1;
1518 }
1519 cur
1520 }
1521
1522 fn align_boundary_backward(&self, messages: &[Message], idx: usize) -> usize {
1523 if idx == 0 || idx >= messages.len() {
1524 return idx;
1525 }
1526 let mut check = idx - 1;
1527 while check > 0 && messages[check].role == "tool" {
1528 check -= 1;
1529 }
1530 if messages[check].role == "assistant" && messages[check].tool_calls.is_some() {
1531 return check;
1532 }
1533 idx
1534 }
1535
1536 fn protect_head_size(&self, messages: &[Message]) -> usize {
1537 let mut head = 0;
1538 if !messages.is_empty() && messages[0].role == "system" {
1539 head = 1;
1540 }
1541 head + self.protect_first_n
1542 }
1543
1544 #[allow(clippy::manual_find)]
1545 fn find_last_user_message_idx(&self, messages: &[Message], head_end: usize) -> Option<usize> {
1546 for i in (head_end..messages.len()).rev() {
1547 if messages[i].role == "user" {
1548 return Some(i);
1549 }
1550 }
1551 None
1552 }
1553
1554 fn ensure_last_user_message_in_tail(
1555 &self,
1556 messages: &[Message],
1557 cut_idx: usize,
1558 head_end: usize,
1559 ) -> usize {
1560 let last_user_idx = self.find_last_user_message_idx(messages, head_end);
1561 match last_user_idx {
1562 None => cut_idx,
1563 Some(idx) => {
1564 if idx >= cut_idx {
1565 cut_idx
1566 } else {
1567 idx.max(head_end + 1)
1568 }
1569 }
1570 }
1571 }
1572
1573 fn find_tail_cut_by_tokens(
1574 &self,
1575 messages: &[Message],
1576 head_end: usize,
1577 token_budget: Option<usize>,
1578 ) -> usize {
1579 let budget = token_budget.unwrap_or(self.tail_token_budget);
1580 let n = messages.len();
1581 let min_tail = if n - head_end > 1 {
1582 3.min(n - head_end - 1)
1583 } else {
1584 0
1585 };
1586 let soft_ceiling = (budget as f64 * 1.5) as usize;
1587 let mut accumulated = 0;
1588 let mut cut_idx = n;
1589
1590 for i in (head_end..n).rev() {
1591 let msg = &messages[i];
1592 let content_len = content_length_for_budget(&msg.content);
1593 let mut msg_tokens = content_len / CHARS_PER_TOKEN + 10;
1594 if let Some(Value::Array(calls)) = &msg.tool_calls {
1595 for tc in calls {
1596 let args = tc
1597 .get("function")
1598 .and_then(|f| f.get("arguments"))
1599 .and_then(|v| v.as_str())
1600 .unwrap_or("");
1601 msg_tokens += args.len() / CHARS_PER_TOKEN;
1602 }
1603 }
1604 if accumulated + msg_tokens > soft_ceiling && (n - i) >= min_tail {
1605 break;
1606 }
1607 accumulated += msg_tokens;
1608 cut_idx = i;
1609 }
1610
1611 let fallback_cut = n - min_tail;
1612 cut_idx = cut_idx.min(fallback_cut);
1613
1614 if cut_idx <= head_end {
1615 cut_idx = fallback_cut.max(head_end + 1);
1616 }
1617
1618 cut_idx = self.align_boundary_backward(messages, cut_idx);
1619 cut_idx = self.ensure_last_user_message_in_tail(messages, cut_idx, head_end);
1620
1621 cut_idx.max(head_end + 1)
1622 }
1623
1624 pub fn has_content_to_compress(&self, messages: &[Message]) -> bool {
1625 let compress_start =
1626 self.align_boundary_forward(messages, self.protect_head_size(messages));
1627 let compress_end = self.find_tail_cut_by_tokens(messages, compress_start, None);
1628 compress_start < compress_end
1629 }
1630
1631 #[allow(clippy::needless_range_loop)]
1632 pub async fn compress(
1633 &mut self,
1634 messages: &[Message],
1635 current_tokens: Option<usize>,
1636 focus_topic: Option<&str>,
1637 force: bool,
1638 ) -> Vec<Message> {
1639 self.last_summary_dropped_count = 0;
1640 self.last_summary_fallback_used = false;
1641 self.last_summary_error = None;
1642 self.last_compress_aborted = false;
1643
1644 if force && self.summary_failure_cooldown_until > 0.0 {
1645 self.summary_failure_cooldown_until = 0.0;
1646 }
1647
1648 let n_messages = messages.len();
1649 let min_for_compress = self.protect_head_size(messages) + 3 + 1;
1650 if n_messages <= min_for_compress {
1651 return messages.to_vec();
1652 }
1653
1654 let display_tokens = current_tokens.unwrap_or_else(|| {
1655 if self.last_prompt_tokens > 0 {
1656 self.last_prompt_tokens
1657 } else {
1658 estimate_messages_tokens_rough(messages)
1659 }
1660 });
1661
1662 let (pruned_messages, _pruned_count) =
1664 self.prune_old_tool_results(messages, self.protect_last_n, Some(self.tail_token_budget));
1665
1666 let mut compress_start = self.protect_head_size(&pruned_messages);
1668 compress_start = self.align_boundary_forward(&pruned_messages, compress_start);
1669 let compress_end = self.find_tail_cut_by_tokens(&pruned_messages, compress_start, None);
1670
1671 if compress_start >= compress_end {
1672 return pruned_messages;
1673 }
1674
1675 let mut turns_to_summarize = pruned_messages[compress_start..compress_end].to_vec();
1676
1677 let summary_search_start =
1679 if !pruned_messages.is_empty() && pruned_messages[0].role == "system" {
1680 1
1681 } else {
1682 0
1683 };
1684 let (summary_idx, summary_body) = self.find_latest_context_summary(
1685 &pruned_messages,
1686 summary_search_start,
1687 compress_end,
1688 );
1689
1690 if let Some(s_idx) = summary_idx {
1691 if !summary_body.is_empty() && self.previous_summary.is_none() {
1692 self.previous_summary = Some(summary_body);
1693 }
1694 turns_to_summarize = pruned_messages[compress_start.max(s_idx + 1)..compress_end].to_vec();
1695 }
1696
1697 let mut summary = self.generate_summary(&turns_to_summarize, focus_topic).await;
1699
1700 if summary.is_none() && self.abort_on_summary_failure {
1701 self.last_compress_aborted = true;
1702 return pruned_messages;
1703 }
1704
1705 let mut compressed = Vec::new();
1707 for i in 0..compress_start {
1708 let mut msg = pruned_messages[i].clone();
1709 if i == 0 && msg.role == "system" {
1710 let existing = msg.content.clone();
1711 let compression_note = "\
1712[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. \
1713The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. \
1714Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]";
1715 if !content_text_for_contains(&existing).contains("[Note: Some earlier conversation turns") {
1716 let text = if let Value::String(s) = &existing {
1717 if !s.is_empty() {
1718 format!("\n\n{}", compression_note)
1719 } else {
1720 compression_note.to_string()
1721 }
1722 } else {
1723 compression_note.to_string()
1724 };
1725 msg.content = append_text_to_content(&existing, &text, false);
1726 }
1727 }
1728 compressed.push(msg);
1729 }
1730
1731 if summary.is_none() {
1733 let n_dropped = compress_end - compress_start;
1734 self.last_summary_dropped_count = n_dropped;
1735 self.last_summary_fallback_used = true;
1736 summary = Some(format!("{}\n\
1737Summary generation was unavailable. {} message(s) were \
1738removed to free context space but could not be summarized. The removed \
1739messages contained earlier work in this session. Continue based on the \
1740recent messages below and the current state of any files or resources.", SUMMARY_PREFIX, n_dropped));
1741 }
1742
1743 let mut merge_summary_into_tail = false;
1744 let last_head_role = if compress_start > 0 {
1745 pruned_messages[compress_start - 1].role.as_str()
1746 } else {
1747 "user"
1748 };
1749 let first_tail_role = if compress_end < n_messages {
1750 pruned_messages[compress_end].role.as_str()
1751 } else {
1752 "user"
1753 };
1754
1755 let mut summary_role = if last_head_role == "assistant" || last_head_role == "tool" {
1756 "user".to_string()
1757 } else {
1758 "assistant".to_string()
1759 };
1760
1761 if summary_role == first_tail_role {
1762 let flipped = if summary_role == "user" {
1763 "assistant"
1764 } else {
1765 "user"
1766 };
1767 if flipped != last_head_role {
1768 summary_role = flipped.to_string();
1769 } else {
1770 merge_summary_into_tail = true;
1771 }
1772 }
1773
1774 let mut summary_text = summary.unwrap();
1775 if !merge_summary_into_tail && summary_role == "user" {
1776 summary_text = format!("{}\n\n--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---", summary_text);
1777 }
1778
1779 if !merge_summary_into_tail {
1780 compressed.push(Message {
1781 role: summary_role,
1782 content: Value::String(summary_text.clone()),
1783 tool_calls: None,
1784 tool_call_id: None,
1785 });
1786 }
1787
1788 for i in compress_end..n_messages {
1789 let mut msg = pruned_messages[i].clone();
1790 if merge_summary_into_tail && i == compress_end {
1791 let merged_prefix = format!("{}\n\n--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---\n\n", summary_text);
1792 msg.content = append_text_to_content(&msg.content, &merged_prefix, true);
1793 merge_summary_into_tail = false;
1794 }
1795 compressed.push(msg);
1796 }
1797
1798 self.compression_count += 1;
1799
1800 let mut sanitized = self.sanitize_tool_pairs(&compressed);
1801 sanitized = strip_historical_media(&sanitized);
1802
1803 let new_estimate = estimate_messages_tokens_rough(&sanitized);
1804 let saved_estimate = display_tokens.saturating_sub(new_estimate);
1805 let savings_pct = if display_tokens > 0 {
1806 (saved_estimate as f64 / display_tokens as f64) * 100.0
1807 } else {
1808 0.0
1809 };
1810 self.last_compression_savings_pct = savings_pct;
1811
1812 if savings_pct < 10.0 {
1813 self.ineffective_compression_count += 1;
1814 } else {
1815 self.ineffective_compression_count = 0;
1816 }
1817
1818 sanitized
1819 }
1820}