Skip to main content

vtcode_core/context/
history_files.rs

1//! Chat History Files for Dynamic Context Discovery
2//!
3//! Implements Cursor-style chat history persistence during summarization.
4//! When context window fills up and summarization occurs, the full conversation
5//! is written to `.vtcode/history/` so agents can recover details with shell search.
6//!
7//! ## Design Philosophy
8//!
9//! Instead of losing conversation details during lossy summarization:
10//! 1. Write full conversation to `.vtcode/history/session_{id}_{turn}.jsonl`
11//! 2. Include file reference in summary message
12//! 3. Agent can search history with `rg` when details are needed
13
14use crate::llm::provider::{ContentPart, Message, MessageContent, MessageRole};
15use crate::telemetry::perf::PerfSpan;
16use anyhow::{Context, Result};
17use chrono::{DateTime, Utc};
18use hashbrown::HashMap;
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::{Path, PathBuf};
22use tokio::fs as async_fs;
23use tracing::{debug, info};
24
25/// Configuration for history file persistence
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct HistoryConfig {
28    /// Enable history file persistence
29    #[serde(default = "default_enabled")]
30    pub enabled: bool,
31
32    /// Maximum number of history files to keep per session
33    #[serde(default = "default_max_files")]
34    pub max_files_per_session: usize,
35
36    /// Include detailed tool results in history
37    #[serde(default = "default_include_tool_results")]
38    pub include_tool_results: bool,
39}
40
41fn default_enabled() -> bool {
42    true
43}
44
45fn default_max_files() -> usize {
46    10
47}
48
49fn default_include_tool_results() -> bool {
50    true
51}
52
53impl Default for HistoryConfig {
54    fn default() -> Self {
55        Self {
56            enabled: true,
57            max_files_per_session: 10,
58            include_tool_results: true,
59        }
60    }
61}
62
63/// A single message in the history file
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct HistoryMessage {
66    /// Turn number in the conversation
67    pub turn: usize,
68    /// Role: user, assistant, tool
69    pub role: String,
70    /// Message content
71    pub content: String,
72    /// Optional tool call ID
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub tool_call_id: Option<String>,
75    /// Optional tool name
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub tool_name: Option<String>,
78    /// Timestamp
79    pub timestamp: DateTime<Utc>,
80}
81
82/// Metadata about the history file
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct HistoryMetadata {
85    /// Session identifier
86    pub session_id: String,
87    /// Turn number when history was written
88    pub turn_number: usize,
89    /// Reason for writing (e.g., "summarization", "checkpoint")
90    pub reason: String,
91    /// Number of messages in file
92    pub message_count: usize,
93    /// Files modified in this conversation
94    pub modified_files: Vec<String>,
95    /// Commands executed
96    pub executed_commands: Vec<String>,
97    /// Timestamp when written
98    pub written_at: DateTime<Utc>,
99}
100
101/// Result of writing a history file
102#[derive(Debug, Clone)]
103pub struct HistoryWriteResult {
104    /// Path to the history file (relative to workspace)
105    pub file_path: PathBuf,
106    /// Metadata about the file
107    pub metadata: HistoryMetadata,
108}
109
110/// Manager for conversation history files
111pub struct HistoryFileManager {
112    /// Workspace root
113    workspace_root: PathBuf,
114    /// History directory
115    history_dir: PathBuf,
116    /// Session identifier
117    session_id: String,
118    /// Configuration
119    config: HistoryConfig,
120    /// Counter for history files in this session
121    file_counter: usize,
122}
123
124impl HistoryFileManager {
125    /// Create a new history file manager
126    pub fn new(workspace_root: &Path, session_id: impl Into<String>) -> Self {
127        Self::with_config(workspace_root, session_id, HistoryConfig::default())
128    }
129
130    /// Create a new history file manager with custom config
131    pub fn with_config(workspace_root: &Path, session_id: impl Into<String>, config: HistoryConfig) -> Self {
132        let history_dir = workspace_root.join(".vtcode").join("history");
133        Self {
134            workspace_root: workspace_root.to_path_buf(),
135            history_dir,
136            session_id: session_id.into(),
137            config,
138            file_counter: 0,
139        }
140    }
141
142    /// Check if history persistence is enabled
143    pub fn is_enabled(&self) -> bool {
144        self.config.enabled
145    }
146
147    /// Write conversation history to a file (synchronous version)
148    ///
149    /// Returns the file path and metadata if successful.
150    /// Use this when calling from synchronous code paths like summarization.
151    pub fn write_history_sync(
152        &mut self,
153        messages: &[HistoryMessage],
154        turn_number: usize,
155        reason: &str,
156        modified_files: &[String],
157        executed_commands: &[String],
158    ) -> Result<HistoryWriteResult> {
159        let mut perf = PerfSpan::new("vtcode.perf.history_write_ms");
160        perf.tag("mode", "sync");
161        perf.tag("reason", reason.to_string());
162
163        if !self.config.enabled {
164            return Err(anyhow::anyhow!("History persistence is disabled"));
165        }
166
167        // Ensure directory exists
168        fs::create_dir_all(&self.history_dir)
169            .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
170
171        // Generate filename
172        self.file_counter += 1;
173        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
174        let filename = format!("{}_{:04}_{}.jsonl", sanitize_session_id(&self.session_id), turn_number, timestamp);
175        let file_path = self.history_dir.join(&filename);
176
177        // Build metadata
178        let metadata = HistoryMetadata {
179            session_id: self.session_id.clone(),
180            turn_number,
181            reason: reason.to_string(),
182            message_count: messages.len(),
183            modified_files: modified_files.to_vec(),
184            executed_commands: executed_commands.to_vec(),
185            written_at: Utc::now(),
186        };
187
188        // Build file content as JSONL — serialize directly into a byte
189        // buffer to avoid one intermediate `String` allocation per message.
190        let mut content: Vec<u8> = Vec::new();
191
192        // Write metadata as first line
193        serde_json::to_writer(
194            &mut content,
195            &serde_json::json!({
196                "_type": "metadata",
197                "_metadata": metadata
198            }),
199        )?;
200        content.push(b'\n');
201
202        // Write each message as a line
203        for msg in messages {
204            serde_json::to_writer(&mut content, msg)?;
205            content.push(b'\n');
206        }
207
208        // Write file
209        fs::write(&file_path, &content)
210            .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
211
212        // Calculate relative path
213        let relative_path = file_path.strip_prefix(&self.workspace_root).unwrap_or(&file_path).to_path_buf();
214
215        info!(
216            session = %self.session_id,
217            turn = turn_number,
218            messages = messages.len(),
219            path = %relative_path.display(),
220            "Wrote conversation history to file"
221        );
222
223        // Cleanup old files synchronously
224        self.cleanup_old_files_sync();
225
226        Ok(HistoryWriteResult { file_path: relative_path, metadata })
227    }
228
229    /// Write conversation history to a file (async version)
230    ///
231    /// Returns the file path and metadata if successful
232    pub async fn write_history(
233        &mut self,
234        messages: &[HistoryMessage],
235        turn_number: usize,
236        reason: &str,
237        modified_files: &[String],
238        executed_commands: &[String],
239    ) -> Result<HistoryWriteResult> {
240        let mut perf = PerfSpan::new("vtcode.perf.history_write_ms");
241        perf.tag("mode", "async");
242        perf.tag("reason", reason.to_string());
243
244        if !self.config.enabled {
245            return Err(anyhow::anyhow!("History persistence is disabled"));
246        }
247
248        // Ensure directory exists
249        async_fs::create_dir_all(&self.history_dir)
250            .await
251            .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
252
253        // Generate filename
254        self.file_counter += 1;
255        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
256        let filename = format!("{}_{:04}_{}.jsonl", sanitize_session_id(&self.session_id), turn_number, timestamp);
257        let file_path = self.history_dir.join(&filename);
258
259        // Build metadata
260        let metadata = HistoryMetadata {
261            session_id: self.session_id.clone(),
262            turn_number,
263            reason: reason.to_string(),
264            message_count: messages.len(),
265            modified_files: modified_files.to_vec(),
266            executed_commands: executed_commands.to_vec(),
267            written_at: Utc::now(),
268        };
269
270        // Build file content as JSONL — serialize directly into a byte
271        // buffer to avoid one intermediate `String` allocation per message.
272        let mut content: Vec<u8> = Vec::new();
273
274        // Write metadata as first line
275        serde_json::to_writer(
276            &mut content,
277            &serde_json::json!({
278                "_type": "metadata",
279                "_metadata": metadata
280            }),
281        )?;
282        content.push(b'\n');
283
284        // Write each message as a line
285        for msg in messages {
286            serde_json::to_writer(&mut content, msg)?;
287            content.push(b'\n');
288        }
289
290        // Write file
291        async_fs::write(&file_path, &content)
292            .await
293            .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
294
295        // Calculate relative path
296        let relative_path = file_path.strip_prefix(&self.workspace_root).unwrap_or(&file_path).to_path_buf();
297
298        info!(
299            session = %self.session_id,
300            turn = turn_number,
301            messages = messages.len(),
302            path = %relative_path.display(),
303            "Wrote conversation history to file"
304        );
305
306        // Cleanup old files if needed
307        self.cleanup_old_files().await?;
308
309        Ok(HistoryWriteResult { file_path: relative_path, metadata })
310    }
311
312    /// Generate a summary message with file reference
313    pub fn format_summary_with_reference(&self, base_summary: &str, history_path: &Path) -> String {
314        format!(
315            "{}\n\nFull conversation history saved to: {}\nUse `exec_command.cmd` with `rg`, `sed`, or `cat` to inspect specific details if needed.",
316            base_summary,
317            history_path.display()
318        )
319    }
320
321    /// Cleanup old history files beyond the limit (synchronous)
322    fn cleanup_old_files_sync(&self) {
323        if !self.history_dir.exists() {
324            return;
325        }
326
327        let prefix = sanitize_session_id(&self.session_id);
328        let mut files: Vec<PathBuf> = Vec::new();
329
330        if let Ok(entries) = fs::read_dir(&self.history_dir) {
331            for entry in entries.flatten() {
332                let path = entry.path();
333                if let Some(name) = path.file_name().and_then(|n| n.to_str())
334                    && name.starts_with(&prefix)
335                    && name.ends_with(".jsonl")
336                {
337                    files.push(path);
338                }
339            }
340        }
341
342        // Sort by name (which includes timestamp) and remove oldest
343        files.sort();
344        let excess = files.len().saturating_sub(self.config.max_files_per_session);
345
346        for old_file in files.into_iter().take(excess) {
347            if fs::remove_file(&old_file).is_ok() {
348                debug!(path = %old_file.display(), "Removed old history file");
349            }
350        }
351    }
352
353    /// Cleanup old history files beyond the limit (async)
354    async fn cleanup_old_files(&self) -> Result<()> {
355        if !async_fs::try_exists(&self.history_dir).await.unwrap_or(false) {
356            return Ok(());
357        }
358
359        let prefix = sanitize_session_id(&self.session_id);
360        let mut files: Vec<PathBuf> = Vec::new();
361
362        let mut entries = async_fs::read_dir(&self.history_dir).await?;
363        while let Some(entry) = entries.next_entry().await? {
364            let path = entry.path();
365            if let Some(name) = path.file_name().and_then(|n| n.to_str())
366                && name.starts_with(&prefix)
367                && name.ends_with(".jsonl")
368            {
369                files.push(path);
370            }
371        }
372
373        // Sort by name (which includes timestamp) and remove oldest
374        files.sort();
375        let excess = files.len().saturating_sub(self.config.max_files_per_session);
376
377        for old_file in files.into_iter().take(excess) {
378            if async_fs::remove_file(&old_file).await.is_ok() {
379                debug!(path = %old_file.display(), "Removed old history file");
380            }
381        }
382
383        Ok(())
384    }
385}
386
387/// Sanitize session ID for use in filename
388fn sanitize_session_id(id: &str) -> String {
389    id.chars()
390        .map(|c| {
391            if c.is_alphanumeric() || c == '_' || c == '-' {
392                c
393            } else {
394                '_'
395            }
396        })
397        .take(32)
398        .collect()
399}
400
401fn history_text_from_message_content(content: &MessageContent) -> String {
402    match content {
403        MessageContent::Text(text) => text.clone(),
404        MessageContent::Parts(parts) => parts
405            .iter()
406            .map(|part| match part {
407                ContentPart::Text { text } => text.clone(),
408                ContentPart::Image { .. } => "[Image]".to_string(),
409                ContentPart::File { filename, file_id, file_url, .. } => filename
410                    .clone()
411                    .or_else(|| file_id.clone())
412                    .or_else(|| file_url.clone())
413                    .map(|value| format!("[File: {value}]"))
414                    .unwrap_or_else(|| "[File]".to_string()),
415            })
416            .collect::<Vec<_>>()
417            .join("\n"),
418    }
419}
420
421/// Convert provider-agnostic messages into persisted history messages.
422pub fn messages_to_history_messages(messages: &[Message], start_turn: usize) -> Vec<HistoryMessage> {
423    let mut history_messages = Vec::with_capacity(messages.len());
424    let now = Utc::now();
425    let mut tool_names_by_call_id = HashMap::new();
426
427    for (i, message) in messages.iter().enumerate() {
428        let turn = start_turn + i;
429        let role = message.role.as_generic_str().to_string();
430
431        if let Some(tool_calls) = &message.tool_calls {
432            for tool_call in tool_calls {
433                if let Some(function) = &tool_call.function {
434                    tool_names_by_call_id.insert(tool_call.id.clone(), function.name.clone());
435                }
436            }
437        }
438
439        let content = if message.role == MessageRole::Tool {
440            let tool_name = message
441                .origin_tool
442                .clone()
443                .or_else(|| {
444                    message
445                        .tool_call_id
446                        .as_ref()
447                        .and_then(|id| tool_names_by_call_id.get(id).cloned())
448                })
449                .unwrap_or_else(|| "tool".to_string());
450            format!("[Tool response from {}: {}]", tool_name, history_text_from_message_content(&message.content))
451        } else {
452            let mut text_parts = Vec::new();
453            let content_text = history_text_from_message_content(&message.content);
454            if !content_text.is_empty() {
455                text_parts.push(content_text);
456            }
457
458            if let Some(reasoning) = message.reasoning.as_ref()
459                && !reasoning.trim().is_empty()
460            {
461                text_parts.push(format!("[Reasoning: {}]", reasoning.trim()));
462            }
463
464            if let Some(tool_calls) = &message.tool_calls {
465                for tool_call in tool_calls {
466                    if let Some(function) = &tool_call.function {
467                        text_parts.push(format!("[Tool call: {} with args: {}]", function.name, function.arguments));
468                    }
469                }
470            }
471
472            text_parts.join("\n")
473        };
474
475        history_messages.push(HistoryMessage {
476            turn,
477            role,
478            content,
479            tool_call_id: message.tool_call_id.clone(),
480            tool_name: message
481                .tool_call_id
482                .as_ref()
483                .and_then(|id| tool_names_by_call_id.get(id).cloned()),
484            timestamp: now,
485        });
486    }
487
488    history_messages
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::llm::provider::{FunctionCall, Message, ToolCall};
495    use tempfile::tempdir;
496
497    #[tokio::test]
498    async fn test_history_manager_creation() {
499        let temp = tempdir().unwrap();
500        let manager = HistoryFileManager::new(temp.path(), "test_session");
501
502        assert!(manager.is_enabled());
503        assert_eq!(manager.session_id, "test_session");
504    }
505
506    #[tokio::test]
507    async fn test_write_history_async() {
508        let temp = tempdir().unwrap();
509        let mut manager = HistoryFileManager::new(temp.path(), "test_session");
510
511        let messages = vec![
512            HistoryMessage {
513                turn: 1,
514                role: "user".to_string(),
515                content: "Hello".to_string(),
516                tool_call_id: None,
517                tool_name: None,
518                timestamp: Utc::now(),
519            },
520            HistoryMessage {
521                turn: 2,
522                role: "assistant".to_string(),
523                content: "Hi there".to_string(),
524                tool_call_id: None,
525                tool_name: None,
526                timestamp: Utc::now(),
527            },
528        ];
529
530        let result = manager
531            .write_history(&messages, 5, "summarization", &["file.rs".to_string()], &["cargo build".to_string()])
532            .await
533            .unwrap();
534
535        assert!(result.file_path.to_string_lossy().contains("test_session"));
536        assert_eq!(result.metadata.message_count, 2);
537        assert_eq!(result.metadata.turn_number, 5);
538    }
539
540    #[test]
541    fn test_write_history_sync() {
542        let temp = tempdir().unwrap();
543        let mut manager = HistoryFileManager::new(temp.path(), "test_session_sync");
544
545        let messages = vec![HistoryMessage {
546            turn: 1,
547            role: "user".to_string(),
548            content: "Hello sync".to_string(),
549            tool_call_id: None,
550            tool_name: None,
551            timestamp: Utc::now(),
552        }];
553
554        let result = manager.write_history_sync(&messages, 3, "test", &[], &[]).unwrap();
555
556        assert!(result.file_path.to_string_lossy().contains("test_session_sync"));
557        assert_eq!(result.metadata.message_count, 1);
558    }
559
560    #[test]
561    fn test_sanitize_session_id() {
562        assert_eq!(sanitize_session_id("simple"), "simple");
563        assert_eq!(sanitize_session_id("with spaces"), "with_spaces");
564        assert_eq!(sanitize_session_id("a/b/c"), "a_b_c");
565    }
566
567    #[test]
568    fn test_format_summary_with_reference() {
569        let temp = tempdir().unwrap();
570        let manager = HistoryFileManager::new(temp.path(), "test");
571
572        let summary =
573            manager.format_summary_with_reference("Summarized 10 turns.", Path::new(".vtcode/history/test.jsonl"));
574
575        assert!(summary.contains("Summarized 10 turns"));
576        assert!(summary.contains(".vtcode/history/test.jsonl"));
577        assert!(summary.contains("exec_command.cmd"));
578    }
579
580    #[test]
581    fn messages_to_history_messages_preserves_tool_names() {
582        let messages = vec![
583            Message::assistant_with_tools(
584                "Calling tool".to_string(),
585                vec![ToolCall {
586                    id: "call_1".to_string(),
587                    call_type: "function".to_string(),
588                    function: Some(FunctionCall {
589                        namespace: None,
590                        name: "read_file".to_string(),
591                        arguments: "{\"path\":\"src/main.rs\"}".to_string(),
592                    }),
593                    text: None,
594                    thought_signature: None,
595                }],
596            ),
597            Message::tool_response("call_1".to_string(), "{\"ok\":true}".to_string()),
598        ];
599
600        let history = messages_to_history_messages(&messages, 4);
601        assert_eq!(history.len(), 2);
602        assert_eq!(history[0].turn, 4);
603        assert!(history[0].content.contains("read_file"));
604        assert_eq!(history[1].tool_name.as_deref(), Some("read_file"));
605        assert!(history[1].content.contains("Tool response from read_file"));
606    }
607}