//! `compact` command: distills the current room's Claude Code session //! transcript into a size-capped digest, writes it as a seed, and clears the //! session so the next turn starts fresh with only the distilled context. //! //! Parsing and the transcript→digest→seed pipeline are transport-agnostic //! and live in [`crate::chat::compact`]; this module is the Matrix-specific //! glue around the shared [`ConversationHistory`], mirroring [`super::reset`]. use crate::chat::compact::{self, CompactError}; use crate::chat::transport::matrix::bot::{ConversationHistory, RoomConversation}; use matrix_sdk::ruma::OwnedRoomId; use std::path::Path; /// Re-exported so Matrix call sites read as `compact::extract_compact_command`, /// matching the sibling `reset` module's shape. pub use crate::chat::compact::extract_compact_command; /// Handle a `compact` command: distill the room's session transcript into a /// digest, write it as a seed for the next turn, clear the session ID and /// entries (like `reset`), and return a confirmation with before/after sizes. /// /// `project_root` doubles as the working directory the `claude` CLI was /// spawned with for this room (matrix's `handle_message` always runs Claude /// Code in `services.project_root` — the gateway config dir in gateway mode, /// or the project root in standalone mode — so the session transcript lives /// under the matching `~/.claude/projects//` directory). /// /// Returns an actionable message and changes nothing when there is no active /// session, or when the session transcript cannot be read. pub async fn handle_compact( room_id: &OwnedRoomId, history: &ConversationHistory, project_root: &Path, max_bytes: usize, ) -> String { let mut guard = history.lock().await; let conv = guard .entry(room_id.clone()) .or_insert_with(RoomConversation::default); let Some(session_id) = conv.session_id.clone() else { return "No active session to compact. Send a message first to start one.".to_string(); }; let transcript_path = compact::transcript::transcript_path(project_root, &session_id); match compact::compact_session(project_root, &transcript_path, room_id.as_ref(), max_bytes) { Ok(outcome) => { conv.session_id = None; conv.entries.clear(); conv.pending_seed = Some(outcome.digest); crate::chat::transport::matrix::bot::save_history(project_root, &guard); crate::slog!( "[matrix-bot] compact: room {room_id} {} -> {} bytes, seed written to {}", outcome.before_bytes, outcome.after_bytes, outcome.seed_path.display() ); format!( "Compacted session context: {} → {} bytes. Starting fresh — the distilled \ summary will be included as background in your next message.", outcome.before_bytes, outcome.after_bytes ) } Err(CompactError::TranscriptUnreadable(e)) => format!( "Could not compact: session transcript is unreadable ({e}). Nothing was changed." ), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::chat::transport::matrix::bot::{ConversationEntry, ConversationRole}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; fn room_id() -> OwnedRoomId { "!test:example.com".parse().unwrap() } #[tokio::test] async fn no_active_session_returns_actionable_message_and_changes_nothing() { let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new())); let tmp = tempfile::tempdir().unwrap(); let response = handle_compact(&room_id(), &history, tmp.path(), 8_000).await; assert!( response.contains("No active session"), "expected actionable no-session message: {response}" ); let guard = history.lock().await; assert!( guard.get(&room_id()).is_none_or(|c| c.entries.is_empty()), "no entries should be created for a compact with no session" ); } #[tokio::test] async fn unreadable_transcript_returns_actionable_message_and_preserves_session() { let history: ConversationHistory = Arc::new(TokioMutex::new({ let mut m = HashMap::new(); m.insert( room_id(), RoomConversation { session_id: Some("missing-session".to_string()), entries: vec![], pending_seed: None, last_compact_suggested_at_ms: None, }, ); m })); let tmp = tempfile::tempdir().unwrap(); let response = handle_compact(&room_id(), &history, tmp.path(), 8_000).await; assert!( response.contains("unreadable"), "expected actionable unreadable-transcript message: {response}" ); let guard = history.lock().await; let conv = guard.get(&room_id()).unwrap(); assert_eq!( conv.session_id.as_deref(), Some("missing-session"), "session_id must be preserved when compaction fails" ); } #[tokio::test] async fn successful_compact_clears_session_and_sets_pending_seed() { let session_id = "sess-compact-1"; let project_root = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); // SAFETY: this test owns HOME for its duration; no other test in this // process reads HOME concurrently with this call. unsafe { std::env::set_var("HOME", home.path()); } let transcript_dir = compact::transcript::transcript_path(project_root.path(), session_id) .parent() .unwrap() .to_path_buf(); std::fs::create_dir_all(&transcript_dir).unwrap(); let jsonl = r#"{"type":"user","message":{"role":"user","content":"hello"}} {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}"#; std::fs::write(transcript_dir.join(format!("{session_id}.jsonl")), jsonl).unwrap(); let history: ConversationHistory = Arc::new(TokioMutex::new({ let mut m = HashMap::new(); m.insert( room_id(), RoomConversation { session_id: Some(session_id.to_string()), entries: vec![ConversationEntry { role: ConversationRole::User, sender: "@alice:example.com".to_string(), content: "hi".to_string(), }], pending_seed: None, last_compact_suggested_at_ms: None, }, ); m })); let response = handle_compact(&room_id(), &history, project_root.path(), 8_000).await; assert!( response.contains("Compacted session context"), "response should confirm compaction with sizes: {response}" ); let guard = history.lock().await; let conv = guard.get(&room_id()).unwrap(); assert!(conv.session_id.is_none(), "session_id must be cleared"); assert!(conv.entries.is_empty(), "entries must be cleared"); assert_eq!( conv.pending_seed.as_deref(), Some("User: hello\nAssistant: hi there"), "pending_seed must hold the distilled digest" ); } }