Skip to main content

context_compressor/
lib.rs

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    // 1. Prefix patterns
249    for pattern in get_prefix_patterns() {
250        result = pattern
251            .replace_all(&result, |caps: &regex::Captures| mask_token(&caps[0]))
252            .to_string();
253    }
254
255    // 2. ENV assignments
256    result = get_env_assign_re()
257        .replace_all(&result, |caps: &regex::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    // 3. JSON fields
272    result = get_json_field_re()
273        .replace_all(&result, |caps: &regex::Captures| {
274            format!("{}: \x22{}\x22", &caps[1], mask_token(&caps[2]))
275        })
276        .to_string();
277
278    // 4. Auth headers
279    result = get_auth_header_re()
280        .replace_all(&result, |caps: &regex::Captures| {
281            format!("{}{}", &caps[1], mask_token(&caps[2]))
282        })
283        .to_string();
284
285    // 5. Telegram tokens
286    result = get_telegram_re()
287        .replace_all(&result, |caps: &regex::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    // 6. Private keys
295    result = get_private_key_re()
296        .replace_all(&result, "[REDACTED PRIVATE KEY]")
297        .to_string();
298
299    // 7. DB connection strings
300    result = get_db_connstr_re()
301        .replace_all(&result, |caps: &regex::Captures| {
302            format!("{}***{}", &caps[1], &caps[3])
303        })
304        .to_string();
305
306    // 8. JWT tokens
307    result = get_jwt_re()
308        .replace_all(&result, |caps: &regex::Captures| mask_token(&caps[0]))
309        .to_string();
310
311    // 9. URL userinfo
312    result = get_url_userinfo_re()
313        .replace_all(&result, |caps: &regex::Captures| {
314            format!("{}://{}:***@", &caps[1], &caps[2])
315        })
316        .to_string();
317
318    // 10. URL query params
319    result = get_url_with_query_re()
320        .replace_all(&result, |caps: &regex::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    // 11. Form bodies
338    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    // 12. Discord mentions
346    result = get_discord_mention_re()
347        .replace_all(&result, |caps: &regex::Captures| {
348            let bang = &caps[1];
349            format!("<@{}***>", bang)
350        })
351        .to_string();
352
353    // 13. Phone numbers
354    result = get_signal_phone_re()
355        .replace_all(&result, |caps: &regex::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        // Phase 1: Prune old tool results
1663        let (pruned_messages, _pruned_count) =
1664            self.prune_old_tool_results(messages, self.protect_last_n, Some(self.tail_token_budget));
1665
1666        // Phase 2: Determine boundaries
1667        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        // Find latest context summary to restore previous_summary state or iterative updates
1678        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        // Phase 3: Generate summary
1698        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        // Phase 4: Assemble compressed message list
1706        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        // Fallback if summary generation failed
1732        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}