191 lines
7.6 KiB
Rust
191 lines
7.6 KiB
Rust
//! `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/<mangled project_root>/` 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"
|
|
);
|
|
}
|
|
}
|