compact.rs 19 KB

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