Skip to content

Commit 4ed60ee

Browse files
aseemxsbrandonskiser
authored andcommitted
feat: Fix /compact and /summary commands
- Add functionality to summarize and clear conversation history - Allow retrieval of the summary with /summary command - Ensure the model acknowledges the summary in future responses - Modify clear() method to optionally preserve summary - Update UI messages for better user feedback
1 parent a9bbdeb commit 4ed60ee

File tree

4 files changed

+551
-65
lines changed

4 files changed

+551
-65
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use std::collections::VecDeque;
2+
use fig_api_client::model::ChatMessage;
3+
4+
/// Character count warning levels for conversation size
5+
#[derive(Debug, Clone, PartialEq, Eq)]
6+
pub enum TokenWarningLevel {
7+
/// No warning, conversation is within normal limits
8+
None,
9+
/// Critical level - at single warning threshold (500K characters)
10+
Critical,
11+
}
12+
13+
/// Constants for character-based warning threshold
14+
pub const MAX_CHARS: usize = 500000; // Character-based warning threshold
15+
16+
/// State for tracking summarization process
17+
#[derive(Debug, Clone)]
18+
pub struct SummarizationState {
19+
/// The saved original history
20+
pub original_history: Option<VecDeque<ChatMessage>>,
21+
/// Optional custom prompt used for summarization
22+
pub custom_prompt: Option<String>,
23+
/// Whether to show the summary after compacting
24+
pub show_summary: bool,
25+
}
26+
27+
impl SummarizationState {
28+
#[allow(dead_code)]
29+
pub fn new() -> Self {
30+
Self {
31+
original_history: None,
32+
custom_prompt: None,
33+
show_summary: false,
34+
}
35+
}
36+
37+
pub fn with_prompt(prompt: Option<String>) -> Self {
38+
Self {
39+
original_history: None,
40+
custom_prompt: prompt,
41+
show_summary: false,
42+
}
43+
}
44+
}

crates/q_cli/src/cli/chat/command.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub enum Command {
1818
Profile { subcommand: ProfileSubcommand },
1919
Context { subcommand: ContextSubcommand },
2020
PromptEditor { initial_text: Option<String> },
21+
Compact { prompt: Option<String>, show_summary: bool, help: bool },
2122
Tools { subcommand: Option<ToolsSubcommand> },
2223
}
2324

@@ -224,6 +225,47 @@ impl Command {
224225
return Ok(match parts[0].to_lowercase().as_str() {
225226
"clear" => Self::Clear,
226227
"help" => Self::Help,
228+
"compact" => {
229+
let mut prompt = None;
230+
let mut show_summary = false;
231+
let mut help = false;
232+
233+
// Check if "help" is the first subcommand
234+
if parts.len() > 1 && parts[1].to_lowercase() == "help" {
235+
help = true;
236+
} else {
237+
let mut remaining_parts = Vec::new();
238+
239+
// Parse the parts to handle both prompt and flags
240+
for part in &parts[1..] {
241+
if *part == "--summary" {
242+
show_summary = true;
243+
} else {
244+
remaining_parts.push(*part);
245+
}
246+
}
247+
248+
// Check if the last word is "--summary" (which would have been captured as part of the prompt)
249+
if !remaining_parts.is_empty() {
250+
let last_idx = remaining_parts.len() - 1;
251+
if remaining_parts[last_idx] == "--summary" {
252+
remaining_parts.pop();
253+
show_summary = true;
254+
}
255+
}
256+
257+
// If we have remaining parts after parsing flags, join them as the prompt
258+
if !remaining_parts.is_empty() {
259+
prompt = Some(remaining_parts.join(" "));
260+
}
261+
}
262+
263+
Self::Compact {
264+
prompt,
265+
show_summary,
266+
help,
267+
}
268+
},
227269
"acceptall" => {
228270
let _ = queue!(
229271
output,
@@ -519,7 +561,21 @@ mod tests {
519561
}
520562
};
521563
}
564+
macro_rules! compact {
565+
($prompt:expr, $show_summary:expr) => {
566+
Command::Compact {
567+
prompt: $prompt,
568+
show_summary: $show_summary,
569+
help: false,
570+
}
571+
};
572+
}
522573
let tests = &[
574+
("/compact", compact!(None, false)),
575+
("/compact --summary", compact!(None, true)),
576+
("/compact custom prompt", compact!(Some("custom prompt".to_string()), false)),
577+
("/compact --summary custom prompt", compact!(Some("custom prompt".to_string()), true)),
578+
("/compact custom prompt --summary", compact!(Some("custom prompt".to_string()), true)),
523579
("/profile list", profile!(ProfileSubcommand::List)),
524580
(
525581
"/profile create new_profile",

crates/q_cli/src/cli/chat/conversation_state.rs

Lines changed: 129 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use tracing::{
3232
warn,
3333
};
3434

35+
use super::chat_state::{MAX_CHARS, TokenWarningLevel};
3536
use super::context::ContextManager;
3637
use super::tools::{
3738
QueuedTool,
@@ -72,6 +73,8 @@ pub struct ConversationState {
7273
pub context_manager: Option<ContextManager>,
7374
/// Cached value representing the length of the user context message.
7475
context_message_length: Option<usize>,
76+
/// Stores the latest conversation summary created by /compact
77+
pub latest_summary: Option<String>,
7578
}
7679

7780
impl ConversationState {
@@ -113,13 +116,27 @@ impl ConversationState {
113116
.collect(),
114117
context_manager,
115118
context_message_length: None,
119+
latest_summary: None,
116120
}
117121
}
118122

119-
/// Clears the conversation history.
120-
pub fn clear(&mut self) {
123+
/// Returns a vector representation of the history (for accessors)
124+
pub fn history_as_vec(&self) -> Vec<ChatMessage> {
125+
self.history.iter().cloned().collect()
126+
}
127+
128+
/// Returns the length of the conversation history
129+
pub fn history_len(&self) -> usize {
130+
self.history.len()
131+
}
132+
133+
/// Clears the conversation history and optionally the summary.
134+
pub fn clear(&mut self, preserve_summary: bool) {
121135
self.next_message = None;
122136
self.history.clear();
137+
if !preserve_summary {
138+
self.latest_summary = None;
139+
}
123140
}
124141

125142
pub async fn append_new_user_message(&mut self, input: String) {
@@ -394,44 +411,57 @@ impl ConversationState {
394411
}
395412

396413
/// Returns a pair of user and assistant messages to include as context in the message history
397-
/// depending on [Self::context_manager].
414+
/// including both summaries and context files if available.
398415
pub async fn context_messages(&self) -> Option<(UserInputMessage, AssistantResponseMessage)> {
399-
let Some(context_manager) = &self.context_manager else {
400-
return None;
401-
};
402-
403-
match context_manager.get_context_files(true).await {
404-
Ok(files) => {
405-
if !files.is_empty() {
406-
let mut context_content = String::new();
407-
context_content.push_str("--- CONTEXT FILES BEGIN ---\n");
408-
for (filename, content) in files {
409-
context_content.push_str(&format!("[{}]\n{}\n", filename, content));
416+
let mut context_content = String::new();
417+
let mut has_content = false;
418+
419+
// Add summary if available - emphasize its importance more strongly
420+
if let Some(summary) = &self.latest_summary {
421+
context_content.push_str("--- CRITICAL: PREVIOUS CONVERSATION SUMMARY - THIS IS YOUR PRIMARY CONTEXT ---\n");
422+
context_content.push_str("This summary contains ALL relevant information from our previous conversation including tool uses, results, code analysis, and file operations. YOU MUST reference this information when answering questions and explicitly acknowledge specific details from the summary when they're relevant to the current question.\n\n");
423+
context_content.push_str("SUMMARY CONTENT:\n");
424+
context_content.push_str(summary);
425+
context_content.push_str("\n--- END SUMMARY - YOU MUST USE THIS INFORMATION IN YOUR RESPONSES ---\n\n");
426+
has_content = true;
427+
}
428+
429+
// Add context files if available
430+
if let Some(context_manager) = &self.context_manager {
431+
match context_manager.get_context_files(true).await {
432+
Ok(files) => {
433+
if !files.is_empty() {
434+
context_content.push_str("--- CONTEXT FILES BEGIN ---\n");
435+
for (filename, content) in files {
436+
context_content.push_str(&format!("[{}]\n{}\n", filename, content));
437+
}
438+
context_content.push_str("--- CONTEXT FILES END ---\n\n");
439+
has_content = true;
410440
}
411-
context_content.push_str("--- CONTEXT FILES END ---\n\n");
412-
413-
let user_msg = UserInputMessage {
414-
content: format!(
415-
"Here is some information from my local q rules files, use these when answering questions:\n\n{}",
416-
context_content
417-
),
418-
user_input_message_context: None,
419-
user_intent: None,
420-
};
421-
let assistant_msg = AssistantResponseMessage {
422-
message_id: None,
423-
content: "I will use this when generating my response.".into(),
424-
tool_uses: None,
425-
};
426-
Some((user_msg, assistant_msg))
427-
} else {
428-
None
429-
}
430-
},
431-
Err(e) => {
432-
warn!("Failed to get context files: {}", e);
433-
None
434-
},
441+
},
442+
Err(e) => {
443+
warn!("Failed to get context files: {}", e);
444+
},
445+
}
446+
}
447+
448+
if has_content {
449+
let user_msg = UserInputMessage {
450+
content: format!(
451+
"Here is critical information you MUST consider when answering questions:\n\n{}",
452+
context_content
453+
),
454+
user_input_message_context: None,
455+
user_intent: None,
456+
};
457+
let assistant_msg = AssistantResponseMessage {
458+
message_id: None,
459+
content: "I will fully incorporate this information when generating my responses, and explicitly acknowledge relevant parts of the summary when answering questions.".into(),
460+
tool_uses: None,
461+
};
462+
Some((user_msg, assistant_msg))
463+
} else {
464+
None
435465
}
436466
}
437467

@@ -440,6 +470,67 @@ impl ConversationState {
440470
self.context_message_length
441471
}
442472

473+
/// Calculate the total character count in the conversation
474+
pub fn calculate_char_count(&self) -> usize {
475+
// Calculate total character count in all messages
476+
let mut total_chars = 0;
477+
478+
// Count characters in history
479+
for message in &self.history {
480+
match message {
481+
ChatMessage::UserInputMessage(msg) => {
482+
total_chars += msg.content.len();
483+
if let Some(ctx) = &msg.user_input_message_context {
484+
// Add tool result characters if any
485+
if let Some(results) = &ctx.tool_results {
486+
for result in results {
487+
for content in &result.content {
488+
match content {
489+
ToolResultContentBlock::Text(text) => total_chars += text.len(),
490+
ToolResultContentBlock::Json(doc) => {
491+
if let Some(s) = doc.as_string() {
492+
total_chars += s.len();
493+
} else {
494+
// Approximate JSON size
495+
total_chars += 100;
496+
}
497+
}
498+
}
499+
}
500+
}
501+
}
502+
}
503+
},
504+
ChatMessage::AssistantResponseMessage(msg) => {
505+
total_chars += msg.content.len();
506+
// Add tool uses if any
507+
if let Some(tool_uses) = &msg.tool_uses {
508+
// Approximation for tool uses
509+
total_chars += tool_uses.len() * 200;
510+
}
511+
}
512+
}
513+
}
514+
515+
// Add summary if it exists (it's also in the context sent to the model)
516+
if let Some(summary) = &self.latest_summary {
517+
total_chars += summary.len();
518+
}
519+
520+
total_chars
521+
}
522+
523+
/// Get the current token warning level
524+
pub fn get_token_warning_level(&self) -> TokenWarningLevel {
525+
let total_chars = self.calculate_char_count();
526+
527+
if total_chars >= MAX_CHARS {
528+
TokenWarningLevel::Critical
529+
} else {
530+
TokenWarningLevel::None
531+
}
532+
}
533+
443534
pub fn append_user_transcript(&mut self, message: &str) {
444535
self.append_transcript(format!("> {}", message.replace("\n", "> \n")));
445536
}

0 commit comments

Comments
 (0)