compact.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. use std::fs;
  2. use std::time::{SystemTime, UNIX_EPOCH};
  3. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  4. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  5. pub struct CompactionConfig {
  6. pub preserve_recent_messages: usize,
  7. pub max_estimated_tokens: usize,
  8. }
  9. impl Default for CompactionConfig {
  10. fn default() -> Self {
  11. Self {
  12. preserve_recent_messages: 4,
  13. max_estimated_tokens: 10_000,
  14. }
  15. }
  16. }
  17. #[derive(Debug, Clone, PartialEq, Eq)]
  18. pub struct CompactionResult {
  19. pub summary: String,
  20. pub formatted_summary: String,
  21. pub compacted_session: Session,
  22. pub removed_message_count: usize,
  23. }
  24. #[must_use]
  25. pub fn estimate_session_tokens(session: &Session) -> usize {
  26. session.messages.iter().map(estimate_message_tokens).sum()
  27. }
  28. #[must_use]
  29. pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
  30. session.messages.len() > config.preserve_recent_messages
  31. && estimate_session_tokens(session) >= config.max_estimated_tokens
  32. }
  33. #[must_use]
  34. pub fn format_compact_summary(summary: &str) -> String {
  35. let without_analysis = strip_tag_block(summary, "analysis");
  36. let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
  37. without_analysis.replace(
  38. &format!("<summary>{content}</summary>"),
  39. &format!("Summary:\n{}", content.trim()),
  40. )
  41. } else {
  42. without_analysis
  43. };
  44. collapse_blank_lines(&formatted).trim().to_string()
  45. }
  46. #[must_use]
  47. pub fn get_compact_continuation_message(
  48. summary: &str,
  49. suppress_follow_up_questions: bool,
  50. recent_messages_preserved: bool,
  51. ) -> String {
  52. let mut base = format!(
  53. "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
  54. format_compact_summary(summary)
  55. );
  56. if recent_messages_preserved {
  57. base.push_str("\n\nRecent messages are preserved verbatim.");
  58. }
  59. if suppress_follow_up_questions {
  60. base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
  61. }
  62. base
  63. }
  64. #[must_use]
  65. pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
  66. if !should_compact(session, config) {
  67. return CompactionResult {
  68. summary: String::new(),
  69. formatted_summary: String::new(),
  70. compacted_session: session.clone(),
  71. removed_message_count: 0,
  72. };
  73. }
  74. let keep_from = session
  75. .messages
  76. .len()
  77. .saturating_sub(config.preserve_recent_messages);
  78. let removed = &session.messages[..keep_from];
  79. let preserved = session.messages[keep_from..].to_vec();
  80. let summary = summarize_messages(removed);
  81. let formatted_summary = format_compact_summary(&summary);
  82. persist_compact_summary(&formatted_summary);
  83. let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
  84. let mut compacted_messages = vec![ConversationMessage {
  85. role: MessageRole::System,
  86. blocks: vec![ContentBlock::Text { text: continuation }],
  87. usage: None,
  88. }];
  89. compacted_messages.extend(preserved);
  90. CompactionResult {
  91. summary,
  92. formatted_summary,
  93. compacted_session: Session {
  94. version: session.version,
  95. messages: compacted_messages,
  96. metadata: session.metadata.clone(),
  97. },
  98. removed_message_count: removed.len(),
  99. }
  100. }
  101. fn persist_compact_summary(formatted_summary: &str) {
  102. if formatted_summary.trim().is_empty() {
  103. return;
  104. }
  105. let Ok(cwd) = std::env::current_dir() else {
  106. return;
  107. };
  108. let memory_dir = cwd.join(".claude").join("memory");
  109. if fs::create_dir_all(&memory_dir).is_err() {
  110. return;
  111. }
  112. let path = memory_dir.join(compact_summary_filename());
  113. let _ = fs::write(path, render_memory_file(formatted_summary));
  114. }
  115. fn compact_summary_filename() -> String {
  116. let timestamp = SystemTime::now()
  117. .duration_since(UNIX_EPOCH)
  118. .unwrap_or_default()
  119. .as_secs();
  120. format!("summary-{timestamp}.md")
  121. }
  122. fn render_memory_file(formatted_summary: &str) -> String {
  123. format!("# Project memory\n\n{}\n", formatted_summary.trim())
  124. }
  125. fn summarize_messages(messages: &[ConversationMessage]) -> String {
  126. let user_messages = messages
  127. .iter()
  128. .filter(|message| message.role == MessageRole::User)
  129. .count();
  130. let assistant_messages = messages
  131. .iter()
  132. .filter(|message| message.role == MessageRole::Assistant)
  133. .count();
  134. let tool_messages = messages
  135. .iter()
  136. .filter(|message| message.role == MessageRole::Tool)
  137. .count();
  138. let mut tool_names = messages
  139. .iter()
  140. .flat_map(|message| message.blocks.iter())
  141. .filter_map(|block| match block {
  142. ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
  143. ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
  144. ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
  145. })
  146. .collect::<Vec<_>>();
  147. tool_names.sort_unstable();
  148. tool_names.dedup();
  149. let mut lines = vec![
  150. "<summary>".to_string(),
  151. "Conversation summary:".to_string(),
  152. format!(
  153. "- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
  154. messages.len(),
  155. user_messages,
  156. assistant_messages,
  157. tool_messages
  158. ),
  159. ];
  160. if !tool_names.is_empty() {
  161. lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
  162. }
  163. let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
  164. if !recent_user_requests.is_empty() {
  165. lines.push("- Recent user requests:".to_string());
  166. lines.extend(
  167. recent_user_requests
  168. .into_iter()
  169. .map(|request| format!(" - {request}")),
  170. );
  171. }
  172. let pending_work = infer_pending_work(messages);
  173. if !pending_work.is_empty() {
  174. lines.push("- Pending work:".to_string());
  175. lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
  176. }
  177. let key_files = collect_key_files(messages);
  178. if !key_files.is_empty() {
  179. lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
  180. }
  181. if let Some(current_work) = infer_current_work(messages) {
  182. lines.push(format!("- Current work: {current_work}"));
  183. }
  184. lines.push("- Key timeline:".to_string());
  185. for message in messages {
  186. let role = match message.role {
  187. MessageRole::System => "system",
  188. MessageRole::User => "user",
  189. MessageRole::Assistant => "assistant",
  190. MessageRole::Tool => "tool",
  191. };
  192. let content = message
  193. .blocks
  194. .iter()
  195. .map(summarize_block)
  196. .collect::<Vec<_>>()
  197. .join(" | ");
  198. lines.push(format!(" - {role}: {content}"));
  199. }
  200. lines.push("</summary>".to_string());
  201. lines.join("\n")
  202. }
  203. fn summarize_block(block: &ContentBlock) -> String {
  204. let raw = match block {
  205. ContentBlock::Text { text } => text.clone(),
  206. ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
  207. ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
  208. ContentBlock::ToolResult {
  209. tool_name,
  210. output,
  211. is_error,
  212. ..
  213. } => format!(
  214. "tool_result {tool_name}: {}{output}",
  215. if *is_error { "error " } else { "" }
  216. ),
  217. };
  218. truncate_summary(&raw, 160)
  219. }
  220. fn collect_recent_role_summaries(
  221. messages: &[ConversationMessage],
  222. role: MessageRole,
  223. limit: usize,
  224. ) -> Vec<String> {
  225. messages
  226. .iter()
  227. .filter(|message| message.role == role)
  228. .rev()
  229. .filter_map(|message| first_text_block(message))
  230. .take(limit)
  231. .map(|text| truncate_summary(text, 160))
  232. .collect::<Vec<_>>()
  233. .into_iter()
  234. .rev()
  235. .collect()
  236. }
  237. fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
  238. messages
  239. .iter()
  240. .rev()
  241. .filter_map(first_text_block)
  242. .filter(|text| {
  243. let lowered = text.to_ascii_lowercase();
  244. lowered.contains("todo")
  245. || lowered.contains("next")
  246. || lowered.contains("pending")
  247. || lowered.contains("follow up")
  248. || lowered.contains("remaining")
  249. })
  250. .take(3)
  251. .map(|text| truncate_summary(text, 160))
  252. .collect::<Vec<_>>()
  253. .into_iter()
  254. .rev()
  255. .collect()
  256. }
  257. fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
  258. let mut files = messages
  259. .iter()
  260. .flat_map(|message| message.blocks.iter())
  261. .map(|block| match block {
  262. ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(),
  263. ContentBlock::ToolUse { input, .. } => input.as_str(),
  264. ContentBlock::ToolResult { output, .. } => output.as_str(),
  265. })
  266. .flat_map(extract_file_candidates)
  267. .collect::<Vec<_>>();
  268. files.sort();
  269. files.dedup();
  270. files.into_iter().take(8).collect()
  271. }
  272. fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
  273. messages
  274. .iter()
  275. .rev()
  276. .filter_map(first_text_block)
  277. .find(|text| !text.trim().is_empty())
  278. .map(|text| truncate_summary(text, 200))
  279. }
  280. fn first_text_block(message: &ConversationMessage) -> Option<&str> {
  281. message.blocks.iter().find_map(|block| match block {
  282. ContentBlock::Text { text } | ContentBlock::Thinking { text, .. }
  283. if !text.trim().is_empty() =>
  284. {
  285. Some(text.as_str())
  286. }
  287. ContentBlock::ToolUse { .. }
  288. | ContentBlock::ToolResult { .. }
  289. | ContentBlock::Text { .. }
  290. | ContentBlock::Thinking { .. } => None,
  291. })
  292. }
  293. fn has_interesting_extension(candidate: &str) -> bool {
  294. std::path::Path::new(candidate)
  295. .extension()
  296. .and_then(|extension| extension.to_str())
  297. .is_some_and(|extension| {
  298. ["rs", "ts", "tsx", "js", "json", "md"]
  299. .iter()
  300. .any(|expected| extension.eq_ignore_ascii_case(expected))
  301. })
  302. }
  303. fn extract_file_candidates(content: &str) -> Vec<String> {
  304. content
  305. .split_whitespace()
  306. .filter_map(|token| {
  307. let candidate = token.trim_matches(|char: char| {
  308. matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
  309. });
  310. if candidate.contains('/') && has_interesting_extension(candidate) {
  311. Some(candidate.to_string())
  312. } else {
  313. None
  314. }
  315. })
  316. .collect()
  317. }
  318. fn truncate_summary(content: &str, max_chars: usize) -> String {
  319. if content.chars().count() <= max_chars {
  320. return content.to_string();
  321. }
  322. let mut truncated = content.chars().take(max_chars).collect::<String>();
  323. truncated.push('…');
  324. truncated
  325. }
  326. fn estimate_message_tokens(message: &ConversationMessage) -> usize {
  327. message
  328. .blocks
  329. .iter()
  330. .map(|block| match block {
  331. ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1,
  332. ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
  333. ContentBlock::ToolResult {
  334. tool_name, output, ..
  335. } => (tool_name.len() + output.len()) / 4 + 1,
  336. })
  337. .sum()
  338. }
  339. fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
  340. let start = format!("<{tag}>");
  341. let end = format!("</{tag}>");
  342. let start_index = content.find(&start)? + start.len();
  343. let end_index = content[start_index..].find(&end)? + start_index;
  344. Some(content[start_index..end_index].to_string())
  345. }
  346. fn strip_tag_block(content: &str, tag: &str) -> String {
  347. let start = format!("<{tag}>");
  348. let end = format!("</{tag}>");
  349. if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
  350. let end_index = end_index_rel + end.len();
  351. let mut stripped = String::new();
  352. stripped.push_str(&content[..start_index]);
  353. stripped.push_str(&content[end_index..]);
  354. stripped
  355. } else {
  356. content.to_string()
  357. }
  358. }
  359. fn collapse_blank_lines(content: &str) -> String {
  360. let mut result = String::new();
  361. let mut last_blank = false;
  362. for line in content.lines() {
  363. let is_blank = line.trim().is_empty();
  364. if is_blank && last_blank {
  365. continue;
  366. }
  367. result.push_str(line);
  368. result.push('\n');
  369. last_blank = is_blank;
  370. }
  371. result
  372. }
  373. #[cfg(test)]
  374. mod tests {
  375. use super::{
  376. collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
  377. infer_pending_work, render_memory_file, should_compact, CompactionConfig,
  378. };
  379. use std::fs;
  380. use std::time::{SystemTime, UNIX_EPOCH};
  381. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  382. #[test]
  383. fn formats_compact_summary_like_upstream() {
  384. let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
  385. assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
  386. assert_eq!(
  387. render_memory_file("Summary:\nKept work"),
  388. "# Project memory\n\nSummary:\nKept work\n"
  389. );
  390. }
  391. #[test]
  392. fn leaves_small_sessions_unchanged() {
  393. let session = Session {
  394. version: 1,
  395. messages: vec![ConversationMessage::user_text("hello")],
  396. metadata: None,
  397. };
  398. let result = compact_session(&session, CompactionConfig::default());
  399. assert_eq!(result.removed_message_count, 0);
  400. assert_eq!(result.compacted_session, session);
  401. assert!(result.summary.is_empty());
  402. assert!(result.formatted_summary.is_empty());
  403. }
  404. #[test]
  405. fn persists_compacted_summaries_under_dot_claude_memory() {
  406. let _guard = crate::test_env_lock();
  407. let temp = std::env::temp_dir().join(format!(
  408. "runtime-compact-memory-{}",
  409. SystemTime::now()
  410. .duration_since(UNIX_EPOCH)
  411. .expect("time after epoch")
  412. .as_nanos()
  413. ));
  414. fs::create_dir_all(&temp).expect("temp dir");
  415. let previous = std::env::current_dir().expect("cwd");
  416. std::env::set_current_dir(&temp).expect("set cwd");
  417. let session = Session {
  418. version: 1,
  419. messages: vec![
  420. ConversationMessage::user_text("one ".repeat(200)),
  421. ConversationMessage::assistant(vec![ContentBlock::Text {
  422. text: "two ".repeat(200),
  423. }]),
  424. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  425. ConversationMessage {
  426. role: MessageRole::Assistant,
  427. blocks: vec![ContentBlock::Text {
  428. text: "recent".to_string(),
  429. }],
  430. usage: None,
  431. },
  432. ],
  433. };
  434. let result = compact_session(
  435. &session,
  436. CompactionConfig {
  437. preserve_recent_messages: 2,
  438. max_estimated_tokens: 1,
  439. },
  440. );
  441. let memory_dir = temp.join(".claude").join("memory");
  442. let files = fs::read_dir(&memory_dir)
  443. .expect("memory dir exists")
  444. .flatten()
  445. .map(|entry| entry.path())
  446. .collect::<Vec<_>>();
  447. assert_eq!(result.removed_message_count, 2);
  448. assert_eq!(files.len(), 1);
  449. let persisted = fs::read_to_string(&files[0]).expect("memory file readable");
  450. std::env::set_current_dir(previous).expect("restore cwd");
  451. fs::remove_dir_all(temp).expect("cleanup temp dir");
  452. assert!(persisted.contains("# Project memory"));
  453. assert!(persisted.contains("Summary:"));
  454. }
  455. #[test]
  456. fn compacts_older_messages_into_a_system_summary() {
  457. let session = Session {
  458. version: 1,
  459. messages: vec![
  460. ConversationMessage::user_text("one ".repeat(200)),
  461. ConversationMessage::assistant(vec![ContentBlock::Text {
  462. text: "two ".repeat(200),
  463. }]),
  464. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  465. ConversationMessage {
  466. role: MessageRole::Assistant,
  467. blocks: vec![ContentBlock::Text {
  468. text: "recent".to_string(),
  469. }],
  470. usage: None,
  471. },
  472. ],
  473. metadata: None,
  474. };
  475. let result = compact_session(
  476. &session,
  477. CompactionConfig {
  478. preserve_recent_messages: 2,
  479. max_estimated_tokens: 1,
  480. },
  481. );
  482. assert_eq!(result.removed_message_count, 2);
  483. assert_eq!(
  484. result.compacted_session.messages[0].role,
  485. MessageRole::System
  486. );
  487. assert!(matches!(
  488. &result.compacted_session.messages[0].blocks[0],
  489. ContentBlock::Text { text } if text.contains("Summary:")
  490. ));
  491. assert!(result.formatted_summary.contains("Scope:"));
  492. assert!(result.formatted_summary.contains("Key timeline:"));
  493. assert!(should_compact(
  494. &session,
  495. CompactionConfig {
  496. preserve_recent_messages: 2,
  497. max_estimated_tokens: 1,
  498. }
  499. ));
  500. assert!(
  501. estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
  502. );
  503. }
  504. #[test]
  505. fn truncates_long_blocks_in_summary() {
  506. let summary = super::summarize_block(&ContentBlock::Text {
  507. text: "x".repeat(400),
  508. });
  509. assert!(summary.ends_with('…'));
  510. assert!(summary.chars().count() <= 161);
  511. }
  512. #[test]
  513. fn extracts_key_files_from_message_content() {
  514. let files = collect_key_files(&[ConversationMessage::user_text(
  515. "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
  516. )]);
  517. assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
  518. assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
  519. }
  520. #[test]
  521. fn infers_pending_work_from_recent_messages() {
  522. let pending = infer_pending_work(&[
  523. ConversationMessage::user_text("done"),
  524. ConversationMessage::assistant(vec![ContentBlock::Text {
  525. text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
  526. }]),
  527. ]);
  528. assert_eq!(pending.len(), 1);
  529. assert!(pending[0].contains("Next: update tests"));
  530. }
  531. }