1use crate::config::constants::defaults;
2use crate::config::{HistoryPersistence, VTCodeConfig};
3use crate::llm::provider::{AssistantPhase, Message, MessageContent, MessageRole, ToolCall};
4use crate::telemetry::perf::PerfSpan;
5use crate::utils::dot_config::DotManager;
6use crate::utils::error_log_collector::ErrorLogEntry;
7use crate::utils::file_utils::{
8 ensure_dir_exists, read_json_file, read_json_file_sync, write_json_file, write_json_file_sync,
9};
10use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use regex::RegexBuilder;
13use serde::{Deserialize, Deserializer, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::env;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::process;
19use std::str::FromStr;
20use std::sync::{Arc, Mutex, OnceLock};
21use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
22use uuid::Uuid;
23
24const SESSION_FILE_PREFIX: &str = "session";
25const SESSION_FILE_EXTENSION: &str = "json";
26pub const SESSION_DIR_ENV: &str = "VT_SESSION_DIR";
27pub const SESSION_MAX_FILES_ENV: &str = "VT_SESSION_MAX_FILES";
28pub const SESSION_MAX_AGE_DAYS_ENV: &str = "VT_SESSION_MAX_AGE_DAYS";
29pub const SESSION_MAX_SIZE_MB_ENV: &str = "VT_SESSION_MAX_SIZE_MB";
30const DEFAULT_SESSION_MAX_FILES: usize = 100;
31const DEFAULT_SESSION_MAX_AGE_DAYS: u64 = 14;
32const DEFAULT_SESSION_MAX_SIZE_MB: u64 = 100;
33const BYTES_PER_MB: u64 = 1024 * 1024;
34use crate::core::SECONDS_PER_DAY;
35
36#[derive(Debug, Clone, Copy)]
37struct SessionHistorySettings {
38 persistence: HistoryPersistence,
39 max_bytes: Option<usize>,
40}
41
42impl Default for SessionHistorySettings {
43 fn default() -> Self {
44 Self {
45 persistence: HistoryPersistence::File,
46 max_bytes: None,
47 }
48 }
49}
50
51static SESSION_HISTORY_SETTINGS: OnceLock<Mutex<SessionHistorySettings>> = OnceLock::new();
52
53fn session_history_settings() -> SessionHistorySettings {
54 SESSION_HISTORY_SETTINGS
55 .get()
56 .and_then(|settings| settings.lock().ok().map(|guard| *guard))
57 .unwrap_or_default()
58}
59
60pub fn apply_session_history_config_from_vtcode(config: &VTCodeConfig) {
61 let settings = SessionHistorySettings {
62 persistence: config.history.persistence,
63 max_bytes: config.history.max_bytes,
64 };
65 let cell =
66 SESSION_HISTORY_SETTINGS.get_or_init(|| Mutex::new(SessionHistorySettings::default()));
67 if let Ok(mut guard) = cell.lock() {
68 *guard = settings;
69 }
70}
71
72pub fn history_persistence_enabled() -> bool {
73 matches!(
74 session_history_settings().persistence,
75 HistoryPersistence::File
76 )
77}
78
79#[cfg(test)]
80mod test_env_overrides {
81 use hashbrown::HashMap;
82 use std::ffi::OsString;
83 use std::sync::{LazyLock, Mutex};
84
85 static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<OsString>>>> =
86 LazyLock::new(|| Mutex::new(HashMap::new()));
87
88 pub(super) fn get(key: &str) -> Option<Option<OsString>> {
89 OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
90 }
91
92 pub(super) fn set(key: &str, value: Option<OsString>) {
93 if let Ok(mut map) = OVERRIDES.lock() {
94 map.insert(key.to_owned(), value);
95 }
96 }
97
98 pub(super) fn clear(key: &str) {
99 if let Ok(mut map) = OVERRIDES.lock() {
100 map.remove(key);
101 }
102 }
103}
104
105fn read_env_var_os(key: &str) -> Option<std::ffi::OsString> {
106 #[cfg(test)]
107 if let Some(override_value) = test_env_overrides::get(key) {
108 return override_value;
109 }
110
111 env::var_os(key)
112}
113
114fn read_env_var(key: &str) -> Option<String> {
115 #[cfg(test)]
116 if let Some(override_value) = test_env_overrides::get(key) {
117 return override_value.map(|value| value.to_string_lossy().to_string());
118 }
119
120 env::var(key).ok()
121}
122
123#[cfg(test)]
124fn set_test_env_override_path(key: &str, value: &Path) {
125 test_env_overrides::set(key, Some(value.as_os_str().to_os_string()));
126}
127
128#[cfg(test)]
129fn clear_test_env_override(key: &str) {
130 test_env_overrides::clear(key);
131}
132
133#[cfg(test)]
134pub(crate) fn override_sessions_dir_for_tests(path: &Path) {
135 set_test_env_override_path(SESSION_DIR_ENV, path);
136}
137
138#[cfg(test)]
139pub(crate) fn clear_sessions_dir_override_for_tests() {
140 clear_test_env_override(SESSION_DIR_ENV);
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144pub struct SessionArchiveMetadata {
145 pub workspace_label: String,
146 pub workspace_path: String,
147 pub model: String,
148 pub provider: String,
149 pub theme: String,
150 pub reasoning_effort: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub session_mode: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub debug_log_path: Option<String>,
155 #[serde(default)]
157 pub loaded_skills: Vec<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub prompt_cache_lineage_id: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub external_thread_id: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub parent_session_id: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub fork_mode: Option<SessionForkMode>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub continuation_metadata: Option<SessionContinuationMetadata>,
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "snake_case")]
172pub enum SessionForkMode {
173 FullCopy,
174 Summarized,
175}
176
177#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "snake_case")]
179pub enum SessionContinuationExhaustionReason {
180 MaxBudgetUsd,
181}
182
183#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(rename_all = "snake_case")]
185pub enum SessionContinuationRecommendedAction {
186 ContinueFromSummary,
187 ContinueFullHistory,
188 StartFresh,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct SessionContinuationMetadata {
193 pub exhaustion_reason: SessionContinuationExhaustionReason,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub max_budget_usd_micros: Option<u64>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub actual_cost_usd_micros: Option<u64>,
198 #[serde(default)]
199 pub summary_available: bool,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub recommended_action: Option<SessionContinuationRecommendedAction>,
202}
203
204impl SessionContinuationMetadata {
205 pub fn budget_limit(
206 max_budget_usd: f64,
207 actual_cost_usd: f64,
208 summary_available: bool,
209 ) -> Self {
210 Self {
211 exhaustion_reason: SessionContinuationExhaustionReason::MaxBudgetUsd,
212 max_budget_usd_micros: usd_to_micros(max_budget_usd),
213 actual_cost_usd_micros: usd_to_micros(actual_cost_usd),
214 summary_available,
215 recommended_action: Some(SessionContinuationRecommendedAction::ContinueFromSummary),
216 }
217 }
218
219 pub fn max_budget_usd(&self) -> Option<f64> {
220 self.max_budget_usd_micros.map(micros_to_usd)
221 }
222
223 pub fn actual_cost_usd(&self) -> Option<f64> {
224 self.actual_cost_usd_micros.map(micros_to_usd)
225 }
226
227 pub fn is_budget_limit(&self) -> bool {
228 matches!(
229 self.exhaustion_reason,
230 SessionContinuationExhaustionReason::MaxBudgetUsd
231 )
232 }
233}
234
235fn usd_to_micros(value: f64) -> Option<u64> {
236 if !value.is_finite() || value.is_sign_negative() {
237 return None;
238 }
239
240 Some((value * 1_000_000.0).round() as u64)
241}
242
243fn micros_to_usd(value: u64) -> f64 {
244 value as f64 / 1_000_000.0
245}
246
247impl SessionArchiveMetadata {
248 pub fn new(
249 workspace_label: impl Into<String>,
250 workspace_path: impl Into<String>,
251 model: impl Into<String>,
252 provider: impl Into<String>,
253 theme: impl Into<String>,
254 reasoning_effort: impl Into<String>,
255 ) -> Self {
256 Self {
257 workspace_label: workspace_label.into(),
258 workspace_path: workspace_path.into(),
259 model: model.into(),
260 provider: provider.into(),
261 theme: theme.into(),
262 reasoning_effort: reasoning_effort.into(),
263 session_mode: None,
264 debug_log_path: None,
265 loaded_skills: Vec::new(),
266 prompt_cache_lineage_id: None,
267 external_thread_id: None,
268 parent_session_id: None,
269 fork_mode: None,
270 continuation_metadata: None,
271 }
272 }
273
274 pub fn with_loaded_skills(mut self, skills: Vec<String>) -> Self {
276 self.loaded_skills = skills;
277 self
278 }
279
280 pub fn with_debug_log_path(mut self, path: Option<String>) -> Self {
282 self.debug_log_path = path;
283 self
284 }
285
286 pub fn with_prompt_cache_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
287 self.prompt_cache_lineage_id = Some(lineage_id.into());
288 self
289 }
290
291 pub fn with_external_thread_id(mut self, thread_id: impl Into<String>) -> Self {
292 self.external_thread_id = Some(thread_id.into());
293 self
294 }
295
296 pub fn ensure_prompt_cache_lineage_id(mut self) -> Self {
297 if self.prompt_cache_lineage_id.is_none() {
298 self.prompt_cache_lineage_id = Some(format!("lineage-{}", Uuid::new_v4()));
299 }
300 self
301 }
302
303 pub fn with_parent_session_id(mut self, session_id: impl Into<String>) -> Self {
304 self.parent_session_id = Some(session_id.into());
305 self
306 }
307
308 pub fn with_fork_mode(mut self, fork_mode: SessionForkMode) -> Self {
309 self.fork_mode = Some(fork_mode);
310 self
311 }
312
313 pub fn with_continuation_metadata(
314 mut self,
315 continuation_metadata: Option<SessionContinuationMetadata>,
316 ) -> Self {
317 self.continuation_metadata = continuation_metadata;
318 self
319 }
320
321 pub fn budget_limit_continuation(&self) -> Option<&SessionContinuationMetadata> {
322 self.continuation_metadata
323 .as_ref()
324 .filter(|continuation| continuation.is_budget_limit())
325 }
326
327 fn fork_seed(&self) -> Self {
328 Self {
329 workspace_label: self.workspace_label.clone(),
330 workspace_path: self.workspace_path.clone(),
331 model: self.model.clone(),
332 provider: self.provider.clone(),
333 theme: self.theme.clone(),
334 reasoning_effort: self.reasoning_effort.clone(),
335 session_mode: self.session_mode.clone(),
336 debug_log_path: self.debug_log_path.clone(),
337 loaded_skills: self.loaded_skills.clone(),
338 prompt_cache_lineage_id: self.prompt_cache_lineage_id.clone(),
339 external_thread_id: self.external_thread_id.clone(),
340 parent_session_id: None,
341 fork_mode: None,
342 continuation_metadata: None,
343 }
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
348pub struct SessionMessage {
349 pub role: MessageRole,
350 pub content: MessageContent,
351 #[serde(
353 default,
354 skip_serializing_if = "Option::is_none",
355 deserialize_with = "deserialize_boxed_non_empty_string_opt"
356 )]
357 pub reasoning: Option<Box<String>>,
358 #[serde(
359 default,
360 skip_serializing_if = "Option::is_none",
361 deserialize_with = "deserialize_boxed_non_empty_vec_opt"
362 )]
363 pub reasoning_details: Option<Box<Vec<serde_json::Value>>>,
364 #[serde(
365 default,
366 skip_serializing_if = "Option::is_none",
367 deserialize_with = "deserialize_boxed_non_empty_vec_opt"
368 )]
369 pub tool_calls: Option<Box<Vec<ToolCall>>>,
370 #[serde(default, deserialize_with = "deserialize_boxed_non_empty_string_opt")]
371 pub tool_call_id: Option<Box<String>>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub phase: Option<AssistantPhase>,
374 #[serde(
375 default,
376 skip_serializing_if = "Option::is_none",
377 deserialize_with = "deserialize_boxed_non_empty_string_opt"
378 )]
379 pub origin_tool: Option<Box<String>>,
380}
381
382impl Eq for SessionMessage {}
383
384#[expect(clippy::box_collection)]
385#[inline]
386fn boxed_non_empty_string(value: Option<String>) -> Option<Box<String>> {
387 value.and_then(|value| (!value.is_empty()).then_some(Box::new(value)))
388}
389
390#[expect(clippy::box_collection)]
391#[inline]
392fn boxed_non_empty_vec<T>(value: Option<Vec<T>>) -> Option<Box<Vec<T>>> {
393 value.and_then(|value| (!value.is_empty()).then_some(Box::new(value)))
394}
395
396#[expect(clippy::box_collection)]
397fn clone_non_empty_boxed_string(value: &Option<Box<String>>) -> Option<String> {
398 value
399 .as_deref()
400 .and_then(|value| (!value.is_empty()).then_some(value.to_owned()))
401}
402
403#[expect(clippy::box_collection)]
404fn clone_non_empty_boxed_vec<T: Clone>(value: &Option<Box<Vec<T>>>) -> Option<Vec<T>> {
405 value
406 .as_deref()
407 .and_then(|value| (!value.is_empty()).then_some(value.clone()))
408}
409
410#[expect(clippy::box_collection)]
411fn deserialize_boxed_non_empty_string_opt<'de, D>(
412 deserializer: D,
413) -> Result<Option<Box<String>>, D::Error>
414where
415 D: Deserializer<'de>,
416{
417 Option::<String>::deserialize(deserializer).map(boxed_non_empty_string)
418}
419
420#[expect(clippy::box_collection)]
421fn deserialize_boxed_non_empty_vec_opt<'de, D, T>(
422 deserializer: D,
423) -> Result<Option<Box<Vec<T>>>, D::Error>
424where
425 D: Deserializer<'de>,
426 T: Deserialize<'de>,
427{
428 Option::<Vec<T>>::deserialize(deserializer).map(boxed_non_empty_vec)
429}
430
431impl SessionMessage {
432 fn base(role: MessageRole, content: MessageContent) -> Self {
433 Self {
434 role,
435 content,
436 reasoning: None,
437 reasoning_details: None,
438 tool_calls: None,
439 tool_call_id: None,
440 phase: None,
441 origin_tool: None,
442 }
443 }
444
445 pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
446 Self::base(role, MessageContent::Text(content.into()))
447 }
448
449 pub fn with_content(role: MessageRole, content: MessageContent) -> Self {
450 Self::base(role, content)
451 }
452
453 pub fn with_tool_call_id(
454 role: MessageRole,
455 content: impl Into<String>,
456 tool_call_id: Option<String>,
457 ) -> Self {
458 Self::with_tool_call_id_content(role, MessageContent::Text(content.into()), tool_call_id)
459 }
460
461 pub fn with_tool_call_id_content(
462 role: MessageRole,
463 content: MessageContent,
464 tool_call_id: Option<String>,
465 ) -> Self {
466 let mut message = Self::base(role, content);
467 message.tool_call_id = boxed_non_empty_string(tool_call_id);
468 message
469 }
470}
471
472impl From<&Message> for SessionMessage {
473 fn from(message: &Message) -> Self {
474 Self {
475 role: message.role,
476 content: message.content.clone(),
477 reasoning: boxed_non_empty_string(message.reasoning.clone()),
478 reasoning_details: boxed_non_empty_vec(message.reasoning_details.clone()),
479 tool_calls: boxed_non_empty_vec(message.tool_calls.clone()),
480 tool_call_id: boxed_non_empty_string(message.tool_call_id.clone()),
481 phase: message.phase,
482 origin_tool: boxed_non_empty_string(message.origin_tool.clone()),
483 }
484 }
485}
486
487impl From<&SessionMessage> for Message {
488 fn from(message: &SessionMessage) -> Self {
489 Self {
490 role: message.role,
491 content: message.content.clone(),
492 reasoning: clone_non_empty_boxed_string(&message.reasoning),
493 reasoning_details: clone_non_empty_boxed_vec(&message.reasoning_details),
494 tool_calls: clone_non_empty_boxed_vec(&message.tool_calls),
495 tool_call_id: clone_non_empty_boxed_string(&message.tool_call_id),
496 phase: message.phase,
497 origin_tool: clone_non_empty_boxed_string(&message.origin_tool),
498 }
499 }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
503pub struct SessionSnapshot {
504 pub metadata: SessionArchiveMetadata,
505 pub started_at: DateTime<Utc>,
506 pub ended_at: DateTime<Utc>,
507 pub total_messages: usize,
508 pub distinct_tools: Vec<String>,
509 pub transcript: Vec<String>,
510 #[serde(default)]
511 pub messages: Vec<SessionMessage>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub progress: Option<Box<SessionProgress>>,
515 #[serde(default, skip_serializing_if = "Vec::is_empty")]
517 pub error_logs: Vec<ErrorLogEntry>,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
521pub struct SessionProgress {
522 pub turn_number: usize,
523 #[serde(default)]
524 pub recent_messages: Vec<SessionMessage>,
525 #[serde(default)]
526 pub tool_summaries: Vec<String>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub token_usage: Option<String>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub max_context_tokens: Option<usize>,
531 #[serde(default)]
533 pub loaded_skills: Vec<String>,
534}
535
536#[derive(Debug, Clone)]
537pub struct SessionListing {
538 pub path: PathBuf,
539 pub snapshot: SessionSnapshot,
540}
541
542#[derive(Debug, Clone)]
543pub struct SessionProgressArgs {
544 pub total_messages: usize,
545 pub distinct_tools: Vec<String>,
546 pub recent_messages: Vec<SessionMessage>,
547 pub turn_number: usize,
548 pub token_usage: Option<String>,
549 pub max_context_tokens: Option<usize>,
550 pub loaded_skills: Option<Vec<String>>,
551}
552
553impl SessionListing {
554 pub fn identifier(&self) -> String {
555 self.path
556 .file_stem()
557 .and_then(|value| value.to_str())
558 .map(|value| value.to_string())
559 .unwrap_or_else(|| self.path.display().to_string())
560 }
561
562 pub fn first_prompt_preview(&self) -> Option<String> {
563 self.preview_for_role(MessageRole::User)
564 }
565
566 pub fn first_reply_preview(&self) -> Option<String> {
567 self.preview_for_role(MessageRole::Assistant)
568 }
569
570 fn preview_for_role(&self, role: MessageRole) -> Option<String> {
571 self.snapshot.messages.iter().find_map(|message| {
572 if message.role != role {
573 return None;
574 }
575
576 let text_projection = message.content.as_text();
577 if text_projection.trim().is_empty() {
578 return None;
579 }
580
581 text_projection.lines().find_map(|line| {
582 let trimmed = line.trim();
583 if trimmed.is_empty() {
584 None
585 } else {
586 Some(truncate_preview(trimmed, 80))
587 }
588 })
589 })
590 }
591}
592
593fn normalize_workspace_for_match(path: &Path) -> PathBuf {
594 let absolute = if path.is_absolute() {
595 path.to_path_buf()
596 } else {
597 env::current_dir()
598 .unwrap_or_else(|_| PathBuf::from("."))
599 .join(path)
600 };
601
602 crate::utils::path::normalize_path(&absolute)
603}
604
605pub fn session_workspace_path(listing: &SessionListing) -> Option<PathBuf> {
606 let raw = listing.snapshot.metadata.workspace_path.trim();
607 if raw.is_empty() {
608 None
609 } else {
610 Some(PathBuf::from(raw))
611 }
612}
613
614pub fn session_listing_matches_workspace(listing: &SessionListing, workspace: &Path) -> bool {
615 let Some(session_workspace) = session_workspace_path(listing) else {
616 return false;
617 };
618
619 normalize_workspace_for_match(&session_workspace) == normalize_workspace_for_match(workspace)
620}
621
622fn generate_unique_archive_path(
623 sessions_dir: &Path,
624 metadata: &SessionArchiveMetadata,
625 started_at: DateTime<Utc>,
626 custom_suffix: Option<&str>,
627) -> PathBuf {
628 generate_unique_archive_path_for_label(
629 sessions_dir,
630 &metadata.workspace_label,
631 started_at,
632 custom_suffix,
633 )
634}
635
636fn generate_unique_archive_path_for_label(
637 sessions_dir: &Path,
638 workspace_label: &str,
639 started_at: DateTime<Utc>,
640 custom_suffix: Option<&str>,
641) -> PathBuf {
642 if custom_suffix.is_some() {
643 return sessions_dir.join(archive_file_name_for_label(
644 workspace_label,
645 started_at,
646 custom_suffix,
647 None,
648 ));
649 }
650
651 let mut attempt = 0u32;
652 loop {
653 let candidate = sessions_dir.join(archive_file_name_for_label(
654 workspace_label,
655 started_at,
656 None,
657 Some(attempt),
658 ));
659 if !candidate.exists() {
660 return candidate;
661 }
662 attempt = attempt.wrapping_add(1);
663 }
664}
665
666fn archive_file_name_for_label(
667 workspace_label: &str,
668 started_at: DateTime<Utc>,
669 custom_suffix: Option<&str>,
670 attempt: Option<u32>,
671) -> String {
672 let sanitized_label = sanitize_component(workspace_label);
673 let timestamp = started_at.format("%Y%m%dT%H%M%SZ").to_string();
674
675 if let Some(suffix) = custom_suffix {
676 return format!(
677 "{}-{}-{}-{}.{}",
678 SESSION_FILE_PREFIX,
679 sanitized_label,
680 timestamp,
681 sanitize_component(suffix),
682 SESSION_FILE_EXTENSION
683 );
684 }
685
686 let micros = started_at.timestamp_subsec_micros();
687 let pid = process::id();
688 let attempt_suffix = match attempt.unwrap_or_default() {
689 0 => String::new(),
690 value => format!("-{:02}", value),
691 };
692 format!(
693 "{}-{}-{}_{:06}-{:05}{}.{}",
694 SESSION_FILE_PREFIX,
695 sanitized_label,
696 timestamp,
697 micros,
698 pid,
699 attempt_suffix,
700 SESSION_FILE_EXTENSION
701 )
702}
703
704pub fn generate_session_archive_identifier(
705 workspace_label: &str,
706 custom_suffix: Option<String>,
707) -> String {
708 let file_name = archive_file_name_for_label(
709 workspace_label,
710 Utc::now(),
711 custom_suffix.as_deref(),
712 Some(0),
713 );
714 Path::new(&file_name)
715 .file_stem()
716 .and_then(|stem| stem.to_str())
717 .map(str::to_owned)
718 .unwrap_or_else(|| {
719 format!(
720 "session-{}-{}",
721 sanitize_component(workspace_label),
722 process::id()
723 )
724 })
725}
726
727fn session_identifier_from_archive_path(path: &Path) -> Result<String> {
728 path.file_stem()
729 .and_then(|stem| stem.to_str())
730 .map(|value| value.to_string())
731 .ok_or_else(|| anyhow::anyhow!("failed to derive session identifier from archive path"))
732}
733
734fn is_valid_session_identifier(value: &str) -> bool {
735 !value.is_empty()
736 && value.len() <= 256
737 && value
738 .chars()
739 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
740}
741
742fn validate_session_identifier(session_identifier: &str) -> Result<()> {
743 if is_valid_session_identifier(session_identifier) {
744 return Ok(());
745 }
746
747 Err(anyhow::anyhow!(
748 "Invalid session identifier '{}': only ASCII letters, digits, '-' and '_' are allowed",
749 session_identifier
750 ))
751}
752
753fn session_archive_path_for_identifier(
754 sessions_dir: &Path,
755 session_identifier: &str,
756) -> Result<PathBuf> {
757 validate_session_identifier(session_identifier)?;
758 Ok(sessions_dir.join(format!("{}.{}", session_identifier, SESSION_FILE_EXTENSION)))
759}
760
761fn reserve_new_session_archive_path(
762 sessions_dir: &Path,
763 session_identifier: &str,
764) -> Result<PathBuf> {
765 let path = session_archive_path_for_identifier(sessions_dir, session_identifier)?;
766 if path.exists() {
767 return Err(anyhow::anyhow!(
768 "Session archive identifier '{}' already exists",
769 session_identifier
770 ));
771 }
772
773 Ok(path)
774}
775
776async fn resolve_sessions_dir_for_archive_writes() -> Result<PathBuf> {
777 let sessions_dir = resolve_sessions_dir().await?;
778 apply_session_retention_best_effort(&sessions_dir);
779 Ok(sessions_dir)
780}
781
782pub async fn reserve_session_archive_identifier(
787 workspace_label: &str,
788 custom_suffix: Option<String>,
789) -> Result<String> {
790 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
791 let started_at = Utc::now();
792 let path = generate_unique_archive_path_for_label(
793 &sessions_dir,
794 workspace_label,
795 started_at,
796 custom_suffix.as_deref(),
797 );
798 session_identifier_from_archive_path(&path)
799}
800
801fn progress_transcript_from_recent_messages(recent_messages: &[SessionMessage]) -> Vec<String> {
802 let mut transcript = Vec::with_capacity(recent_messages.len());
803
804 for message in recent_messages {
805 if !matches!(message.role, MessageRole::User | MessageRole::Assistant) {
806 continue;
807 }
808
809 let content = message.content.trim();
810 let content: &str = content.as_ref();
811 if !content.is_empty()
812 && transcript
813 .last()
814 .is_none_or(|last: &String| last.as_str() != content)
815 {
816 transcript.push(content.to_string());
817 }
818 }
819
820 clean_transcript_lines(&transcript)
821}
822
823fn clean_transcript_lines(lines: &[String]) -> Vec<String> {
824 let mut cleaned = Vec::new();
825 let mut seen_tool_blocks: HashMap<String, (usize, usize, String)> = HashMap::new();
826 let mut index = 0usize;
827
828 while index < lines.len() {
829 let line = lines[index].trim_end();
830
831 if should_reset_tool_dedupe_scope(line) {
832 seen_tool_blocks.clear();
833 }
834
835 if let Some(replacement) = normalize_recovery_line(line) {
836 push_clean_transcript_line(&mut cleaned, replacement);
837 index += 1;
838 continue;
839 }
840
841 if should_drop_transcript_line(line) {
842 index += 1;
843 continue;
844 }
845
846 if line.trim_start().starts_with("• ") {
847 let (summary, next_index) = summarize_tool_block(lines, index);
848 let signature = normalized_transcript_key(&summary);
849
850 if let Some((first_index, repeats, original_line)) =
851 seen_tool_blocks.get_mut(&signature)
852 {
853 *repeats += 1;
854 if let Some(existing) = cleaned.get_mut(*first_index) {
855 *existing = format_repeated_summary(original_line, *repeats);
856 }
857 } else {
858 let insertion_index = cleaned.len();
859 push_clean_transcript_line(&mut cleaned, summary);
860 if cleaned.len() > insertion_index {
861 seen_tool_blocks.insert(
862 signature,
863 (insertion_index, 1, cleaned[insertion_index].clone()),
864 );
865 }
866 }
867 index = next_index;
868 continue;
869 }
870
871 push_clean_transcript_line(&mut cleaned, line.to_string());
872 index += 1;
873 }
874
875 while cleaned.last().is_some_and(|line| line.is_empty()) {
876 cleaned.pop();
877 }
878
879 cleaned
880}
881
882fn should_reset_tool_dedupe_scope(line: &str) -> bool {
883 let trimmed = line.trim();
884 !trimmed.is_empty()
885 && !line.starts_with(' ')
886 && !trimmed.starts_with("• ")
887 && !trimmed.starts_with("[!]")
888}
889
890fn should_drop_transcript_line(line: &str) -> bool {
891 let trimmed = line.trim();
892 trimmed.starts_with("Latest tool output:")
893 || trimmed.starts_with("Latest user request:")
894 || trimmed.starts_with("Tool output 1:")
895 || trimmed.starts_with("Structured result with fields:")
896 || trimmed.starts_with("Reuse the latest tool outputs already collected in this turn")
897 || trimmed.starts_with("Interrupt received. Stopping task...")
898}
899
900fn normalize_recovery_line(line: &str) -> Option<String> {
901 let trimmed = line.trim();
902 if trimmed.starts_with("[!] Turn balancer:")
903 || trimmed.starts_with("[!] Navigation Loop:")
904 || trimmed.starts_with("[!] Navigation loop:")
905 {
906 return Some("Repeated low-signal tool churn triggered recovery.".to_string());
907 }
908
909 if trimmed.contains("I couldn't produce a final synthesis because the model returned no answer on the recovery pass.")
910 {
911 return Some("Recovery pass failed to produce a final synthesis.".to_string());
912 }
913
914 None
915}
916
917fn summarize_tool_block(lines: &[String], start: usize) -> (String, usize) {
918 let header = lines[start].trim().to_string();
919 let mut command_continuations = Vec::new();
920 let mut metadata = Vec::new();
921 let mut metadata_seen = HashSet::new();
922 let mut index = start + 1;
923
924 while index < lines.len() {
925 let raw = lines[index].trim_end();
926 let trimmed = raw.trim_start();
927 if trimmed.starts_with("• ") || trimmed.starts_with("[!]") || !is_tool_detail_line(trimmed)
928 {
929 break;
930 }
931
932 if let Some(continuation) = trimmed.strip_prefix("│ ") {
933 let continuation = continuation.trim();
934 if !continuation.is_empty() {
935 command_continuations.push(continuation.to_string());
936 }
937 } else if let Some(extra) = summarize_tool_detail(trimmed)
938 && metadata_seen.insert(extra.clone())
939 {
940 metadata.push(extra);
941 }
942
943 index += 1;
944 }
945
946 let mut summary = header;
947 if !command_continuations.is_empty() {
948 summary.push(' ');
949 summary.push_str(&command_continuations.join(" "));
950 }
951 if !metadata.is_empty() {
952 summary.push_str(" [");
953 summary.push_str(&metadata.join(", "));
954 summary.push(']');
955 }
956
957 (collapse_whitespace(&summary), index)
958}
959
960fn is_tool_detail_line(line: &str) -> bool {
961 line.starts_with("│ ")
962 || line.starts_with("└ ")
963 || line.starts_with("✓ ")
964 || line.starts_with("✗ ")
965 || line.starts_with("… +")
966 || line.starts_with("Large output was spooled")
967 || line == "(no output)"
968}
969
970fn summarize_tool_detail(line: &str) -> Option<String> {
971 let path = line
972 .strip_prefix("└ Path:")
973 .map(str::trim)
974 .filter(|value| !value.is_empty())
975 .map(|value| format!("path {value}"));
976 if path.is_some() {
977 return path;
978 }
979
980 let pattern = line
981 .strip_prefix("└ Pattern:")
982 .map(str::trim)
983 .filter(|value| !value.is_empty())
984 .map(|value| format!("pattern {value}"));
985 if pattern.is_some() {
986 return pattern;
987 }
988
989 let filter = line
990 .strip_prefix("└ Filter:")
991 .map(str::trim)
992 .filter(|value| !value.is_empty())
993 .map(|value| format!("filter {value}"));
994 if filter.is_some() {
995 return filter;
996 }
997
998 let glob = line
999 .strip_prefix("└ Glob:")
1000 .map(str::trim)
1001 .filter(|value| !value.is_empty())
1002 .map(|value| format!("glob {value}"));
1003 if glob.is_some() {
1004 return glob;
1005 }
1006
1007 if let Some(status) = line.strip_prefix("✗ ") {
1008 let status = status.trim();
1009 if !status.is_empty() {
1010 return Some(status.to_string());
1011 }
1012 }
1013
1014 None
1015}
1016
1017fn collapse_whitespace(text: &str) -> String {
1018 vtcode_commons::formatting::collapse_whitespace(text)
1019}
1020
1021fn normalized_transcript_key(text: &str) -> String {
1022 collapse_whitespace(text).to_ascii_lowercase()
1023}
1024
1025fn format_repeated_summary(line: &str, repeats: usize) -> String {
1026 if repeats <= 1 {
1027 return line.to_string();
1028 }
1029 format!("{line} (repeated x{repeats})")
1030}
1031
1032fn push_clean_transcript_line(target: &mut Vec<String>, line: String) {
1033 let trimmed = line.trim();
1034 if trimmed.is_empty() {
1035 if target.last().is_none_or(|last| !last.is_empty()) {
1036 target.push(String::new());
1037 }
1038 return;
1039 }
1040
1041 if target
1042 .last()
1043 .is_some_and(|last| normalized_transcript_key(last) == normalized_transcript_key(trimmed))
1044 {
1045 return;
1046 }
1047
1048 target.push(line);
1049}
1050
1051fn normalize_session_tool_name(name: &str) -> String {
1052 crate::tools::tool_intent::canonical_unified_exec_tool_name(name)
1053 .unwrap_or(name)
1054 .to_string()
1055}
1056
1057fn normalize_distinct_tools_for_summary(distinct_tools: &[String]) -> Vec<String> {
1058 let mut normalized = Vec::with_capacity(distinct_tools.len());
1059 let mut seen = std::collections::BTreeSet::new();
1060
1061 for tool in distinct_tools {
1062 let mapped = normalize_session_tool_name(tool);
1063 if seen.insert(mapped.clone()) {
1064 normalized.push(mapped);
1065 }
1066 }
1067
1068 normalized
1069}
1070
1071#[derive(Debug, Clone)]
1072pub struct SessionArchive {
1073 path: PathBuf,
1074 metadata: SessionArchiveMetadata,
1075 started_at: DateTime<Utc>,
1076 progress_throttle: Arc<Mutex<ProgressThrottle>>,
1077}
1078
1079#[derive(Debug)]
1080struct ProgressThrottle {
1081 last_written: Instant,
1082 last_turn: usize,
1083}
1084
1085impl ProgressThrottle {
1086 fn new() -> Self {
1087 let min_interval =
1088 Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
1089 let last_written = Instant::now()
1090 .checked_sub(min_interval)
1091 .unwrap_or_else(Instant::now);
1092 Self {
1093 last_written,
1094 last_turn: 0,
1095 }
1096 }
1097}
1098
1099impl SessionArchive {
1100 fn from_path(
1101 path: PathBuf,
1102 metadata: SessionArchiveMetadata,
1103 started_at: DateTime<Utc>,
1104 ) -> Self {
1105 Self {
1106 path,
1107 metadata,
1108 started_at,
1109 progress_throttle: Arc::new(Mutex::new(ProgressThrottle::new())),
1110 }
1111 }
1112
1113 fn build_snapshot(
1114 &self,
1115 total_messages: usize,
1116 distinct_tools: Vec<String>,
1117 transcript: Vec<String>,
1118 messages: Vec<SessionMessage>,
1119 progress: Option<SessionProgress>,
1120 ) -> SessionSnapshot {
1121 use crate::utils::error_log_collector::drain_error_logs;
1122
1123 SessionSnapshot {
1124 metadata: self.metadata.clone(),
1125 started_at: self.started_at,
1126 ended_at: Utc::now(),
1127 total_messages,
1128 distinct_tools,
1129 transcript,
1130 messages,
1131 progress: progress.map(Box::new),
1132 error_logs: drain_error_logs(),
1133 }
1134 }
1135
1136 fn build_final_snapshot(
1137 &self,
1138 transcript: Vec<String>,
1139 total_messages: usize,
1140 distinct_tools: Vec<String>,
1141 messages: Vec<SessionMessage>,
1142 ) -> SessionSnapshot {
1143 self.build_snapshot(
1144 total_messages,
1145 normalize_distinct_tools_for_summary(&distinct_tools),
1146 clean_transcript_lines(&transcript),
1147 messages,
1148 None,
1149 )
1150 }
1151
1152 fn build_progress_snapshot(&self, args: SessionProgressArgs) -> SessionSnapshot {
1153 let SessionProgressArgs {
1154 total_messages,
1155 distinct_tools,
1156 recent_messages,
1157 turn_number,
1158 token_usage,
1159 max_context_tokens,
1160 loaded_skills,
1161 } = args;
1162
1163 let transcript = progress_transcript_from_recent_messages(&recent_messages);
1164 let distinct_tools = normalize_distinct_tools_for_summary(&distinct_tools);
1165 let tool_summaries = distinct_tools.clone();
1166 let messages = recent_messages.clone();
1167
1168 self.build_snapshot(
1169 total_messages,
1170 distinct_tools,
1171 transcript,
1172 messages,
1173 Some(SessionProgress {
1174 turn_number,
1175 recent_messages,
1176 tool_summaries,
1177 token_usage,
1178 max_context_tokens,
1179 loaded_skills: loaded_skills.unwrap_or_default(),
1180 }),
1181 )
1182 }
1183
1184 pub async fn new(
1185 metadata: SessionArchiveMetadata,
1186 custom_suffix: Option<String>,
1187 ) -> Result<Self> {
1188 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
1189 let started_at = Utc::now();
1190 let path = generate_unique_archive_path(
1191 &sessions_dir,
1192 &metadata,
1193 started_at,
1194 custom_suffix.as_deref(),
1195 );
1196
1197 Ok(Self::from_path(path, metadata, started_at))
1198 }
1199
1200 pub async fn new_with_identifier(
1202 metadata: SessionArchiveMetadata,
1203 session_identifier: String,
1204 ) -> Result<Self> {
1205 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
1206 let path = reserve_new_session_archive_path(&sessions_dir, &session_identifier)?;
1207
1208 Ok(Self::from_path(path, metadata, Utc::now()))
1209 }
1210
1211 pub fn resume_from_listing(listing: &SessionListing, metadata: SessionArchiveMetadata) -> Self {
1213 Self::from_path(listing.path.clone(), metadata, listing.snapshot.started_at)
1214 }
1215
1216 pub fn finalize(
1217 &self,
1218 transcript: Vec<String>,
1219 total_messages: usize,
1220 distinct_tools: Vec<String>,
1221 messages: Vec<SessionMessage>,
1222 ) -> Result<PathBuf> {
1223 let snapshot =
1224 self.build_final_snapshot(transcript, total_messages, distinct_tools, messages);
1225
1226 let path = self.write_snapshot(snapshot)?;
1227 if let Some(parent) = path.parent() {
1228 apply_session_retention_best_effort(parent);
1229 }
1230 Ok(path)
1231 }
1232
1233 pub fn persist_progress(&self, args: SessionProgressArgs) -> Result<PathBuf> {
1234 let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
1235 perf.tag("mode", "sync");
1236
1237 let snapshot = self.build_progress_snapshot(args);
1238
1239 self.write_snapshot(snapshot)
1240 }
1241
1242 pub async fn persist_progress_async(&self, args: SessionProgressArgs) -> Result<PathBuf> {
1243 let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
1244 perf.tag("mode", "async");
1245
1246 if !self.should_persist_progress(args.turn_number)? {
1247 return Ok(self.path.clone());
1248 }
1249
1250 let snapshot = self.build_progress_snapshot(args);
1251
1252 self.write_snapshot_async(snapshot).await
1253 }
1254
1255 fn write_snapshot(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
1256 let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
1257 return Ok(self.path.clone());
1258 };
1259
1260 write_json_file_sync(&self.path, &snapshot)?;
1261 Ok(self.path.clone())
1262 }
1263
1264 async fn write_snapshot_async(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
1265 let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
1266 return Ok(self.path.clone());
1267 };
1268
1269 write_json_file(&self.path, &snapshot).await?;
1270 Ok(self.path.clone())
1271 }
1272
1273 fn should_persist_progress(&self, turn_number: usize) -> Result<bool> {
1274 let min_interval =
1275 Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
1276 let min_turns = defaults::DEFAULT_SESSION_PROGRESS_MIN_TURN_DELTA;
1277
1278 let mut throttle = self
1279 .progress_throttle
1280 .lock()
1281 .map_err(|err| anyhow::anyhow!("session progress throttle lock poisoned: {err}"))
1282 .context("Failed to evaluate session progress persistence throttle")?;
1283 if turn_number <= throttle.last_turn {
1284 return Ok(false);
1285 }
1286 if throttle.last_written.elapsed() < min_interval
1287 && turn_number.saturating_sub(throttle.last_turn) < min_turns
1288 {
1289 return Ok(false);
1290 }
1291
1292 throttle.last_written = Instant::now();
1293 throttle.last_turn = turn_number;
1294 Ok(true)
1295 }
1296 pub fn set_loaded_skills(&mut self, skills: Vec<String>) {
1298 self.metadata.loaded_skills = skills;
1299 }
1300
1301 pub fn set_continuation_metadata(
1303 &mut self,
1304 continuation_metadata: Option<SessionContinuationMetadata>,
1305 ) {
1306 self.metadata.continuation_metadata = continuation_metadata;
1307 }
1308
1309 pub fn path(&self) -> &Path {
1310 &self.path
1311 }
1312
1313 pub async fn fork(
1326 source_snapshot: &SessionSnapshot,
1327 custom_suffix: Option<String>,
1328 ) -> Result<Self> {
1329 create_fork_archive(source_snapshot, custom_suffix, None).await
1330 }
1331}
1332
1333async fn create_fork_archive(
1334 source_snapshot: &SessionSnapshot,
1335 custom_suffix: Option<String>,
1336 explicit_identifier: Option<String>,
1337) -> Result<SessionArchive> {
1338 let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
1339 let started_at = Utc::now();
1340
1341 let forked_metadata = source_snapshot.metadata.fork_seed();
1342
1343 let path = if let Some(session_identifier) = explicit_identifier {
1344 reserve_new_session_archive_path(&sessions_dir, &session_identifier)?
1345 } else {
1346 generate_unique_archive_path(
1347 &sessions_dir,
1348 &forked_metadata,
1349 started_at,
1350 custom_suffix.as_deref(),
1351 )
1352 };
1353
1354 Ok(SessionArchive::from_path(path, forked_metadata, started_at))
1355}
1356
1357pub async fn list_recent_sessions(limit: usize) -> Result<Vec<SessionListing>> {
1358 let sessions_dir = match resolve_sessions_dir().await {
1359 Ok(dir) => dir,
1360 Err(_) => return Ok(Vec::new()),
1361 };
1362
1363 if !sessions_dir.exists() {
1364 return Ok(Vec::new());
1365 }
1366
1367 let mut session_paths = Vec::new();
1369 for entry in fs::read_dir(&sessions_dir).with_context(|| {
1370 format!(
1371 "failed to read session directory: {}",
1372 sessions_dir.display()
1373 )
1374 })? {
1375 let entry = entry.with_context(|| {
1376 format!("failed to read session entry in {}", sessions_dir.display())
1377 })?;
1378 let path = entry.path();
1379 if is_session_file(&path) {
1380 session_paths.push(path);
1381 }
1382 }
1383
1384 const BATCH_SIZE: usize = 10;
1387 let mut all_listings = Vec::new();
1388
1389 for batch in session_paths.chunks(BATCH_SIZE) {
1390 let mut tasks = Vec::with_capacity(batch.len());
1391
1392 for path in batch {
1393 let path = path.clone();
1394 let task = tokio::task::spawn(async move {
1395 read_json_file::<SessionSnapshot>(&path)
1396 .await
1397 .ok()
1398 .map(|snapshot| SessionListing { path, snapshot })
1399 });
1400 tasks.push(task);
1401 }
1402
1403 for task in tasks {
1405 if let Ok(Some(listing)) = task.await {
1406 all_listings.push(listing);
1407 }
1408 }
1409 }
1410
1411 all_listings.sort_by(|a, b| b.snapshot.ended_at.cmp(&a.snapshot.ended_at));
1413 if limit > 0 && all_listings.len() > limit {
1414 all_listings.truncate(limit);
1415 }
1416
1417 Ok(all_listings)
1418}
1419
1420pub async fn find_session_by_identifier(identifier: &str) -> Result<Option<SessionListing>> {
1422 let sessions_dir = match resolve_sessions_dir().await {
1423 Ok(dir) => dir,
1424 Err(_) => return Ok(None),
1425 };
1426
1427 if !sessions_dir.exists() {
1428 return Ok(None);
1429 }
1430
1431 let path = match session_archive_path_for_identifier(&sessions_dir, identifier) {
1432 Ok(path) => path,
1433 Err(_) => return Ok(None),
1434 };
1435 if !path.exists() {
1436 return Ok(None);
1437 }
1438
1439 let snapshot: SessionSnapshot = read_json_file_sync(&path)?;
1440 Ok(Some(SessionListing { path, snapshot }))
1441}
1442
1443pub async fn find_session_by_id_or_name(id_or_name: &str) -> Result<SessionListing> {
1449 if let Some(listing) = find_session_by_identifier(id_or_name).await? {
1451 return Ok(listing);
1452 }
1453
1454 let listings = list_recent_sessions(200).await?;
1456 let normalized_query = id_or_name.to_lowercase();
1457
1458 for listing in &listings {
1459 if let Some(stem) = listing.path.file_stem()
1461 && let Some(stem_str) = stem.to_str()
1462 && stem_str.to_lowercase().contains(&normalized_query)
1463 {
1464 return Ok(listing.clone());
1465 }
1466 }
1467
1468 Err(anyhow::anyhow!(
1469 "No saved chat found matching '{id_or_name}'."
1470 ))
1471}
1472
1473#[derive(Debug, Clone, Serialize, Deserialize)]
1474pub struct SearchResult {
1475 pub session_id: String,
1476 pub session_path: PathBuf,
1477 pub timestamp: DateTime<Utc>,
1478 pub message_index: usize,
1479 pub role: MessageRole,
1480 pub content_snippet: String,
1481 pub score: f32, }
1483
1484pub async fn search_sessions(
1487 query: &str,
1488 session_limit: usize,
1489 max_results: usize,
1490) -> Result<Vec<SearchResult>> {
1491 if query.trim().is_empty() || max_results == 0 {
1492 return Ok(Vec::new());
1493 }
1494
1495 let listings = list_recent_sessions(session_limit).await?;
1496 let matcher = RegexBuilder::new(®ex::escape(query))
1497 .case_insensitive(true)
1498 .build()
1499 .context("failed to compile session search query")?;
1500 let mut results = Vec::new();
1501
1502 for listing in listings {
1503 for (idx, msg) in listing.snapshot.messages.iter().enumerate() {
1504 let content = match &msg.content {
1505 MessageContent::Text(t) => t.as_str(),
1506 MessageContent::Parts(_) => continue,
1507 };
1508
1509 if let Some(matched) = matcher.find(content) {
1510 let snippet = search_result_snippet(content, matched.start(), matched.end());
1511
1512 results.push(SearchResult {
1513 session_id: listing.identifier(),
1514 session_path: listing.path.clone(),
1515 timestamp: listing.snapshot.started_at,
1516 message_index: idx,
1517 role: msg.role,
1518 content_snippet: snippet,
1519 score: 1.0,
1520 });
1521
1522 if results.len() >= max_results {
1523 break;
1524 }
1525 }
1526 }
1527 if results.len() >= max_results {
1528 break;
1529 }
1530 }
1531
1532 Ok(results)
1533}
1534
1535fn search_result_snippet(content: &str, match_start: usize, match_end: usize) -> String {
1536 const CONTEXT_BYTES: usize = 50;
1537
1538 let start = floor_char_boundary(content, match_start.saturating_sub(CONTEXT_BYTES));
1539 let end = ceil_char_boundary(content, (match_end + CONTEXT_BYTES).min(content.len()));
1540
1541 let mut snippet = String::new();
1542 if start > 0 {
1543 snippet.push_str("...");
1544 }
1545 snippet.push_str(&content[start..end].replace('\n', " "));
1546 if end < content.len() {
1547 snippet.push_str("...");
1548 }
1549 snippet
1550}
1551
1552fn floor_char_boundary(content: &str, mut index: usize) -> usize {
1553 while index > 0 && !content.is_char_boundary(index) {
1554 index -= 1;
1555 }
1556 index
1557}
1558
1559fn ceil_char_boundary(content: &str, mut index: usize) -> usize {
1560 while index < content.len() && !content.is_char_boundary(index) {
1561 index += 1;
1562 }
1563 index
1564}
1565
1566async fn resolve_sessions_dir() -> Result<PathBuf> {
1567 if let Some(custom) = read_env_var_os(SESSION_DIR_ENV) {
1568 let path = PathBuf::from(custom);
1569 ensure_dir_exists(&path).await?;
1570 return Ok(path);
1571 }
1572
1573 let manager = DotManager::new().context("failed to load VT Code dot manager")?;
1574 manager
1575 .initialize()
1576 .await
1577 .context("failed to initialize VT Code dot directory structure")?;
1578 let dir = manager.sessions_dir();
1579 ensure_dir_exists(&dir).await?;
1580 Ok(dir)
1581}
1582
1583#[derive(Debug, Clone)]
1584struct SessionFileEntry {
1585 path: PathBuf,
1586 modified: SystemTime,
1587 size: u64,
1588}
1589
1590#[derive(Debug, Clone, Copy)]
1591struct SessionRetentionLimits {
1592 max_files: usize,
1593 max_age_days: u64,
1594 max_total_size_bytes: u64,
1595}
1596
1597impl Default for SessionRetentionLimits {
1598 fn default() -> Self {
1599 Self {
1600 max_files: DEFAULT_SESSION_MAX_FILES,
1601 max_age_days: DEFAULT_SESSION_MAX_AGE_DAYS,
1602 max_total_size_bytes: DEFAULT_SESSION_MAX_SIZE_MB.saturating_mul(BYTES_PER_MB),
1603 }
1604 }
1605}
1606
1607impl SessionRetentionLimits {
1608 fn from_env() -> Self {
1609 let defaults = Self::default();
1610 Self {
1611 max_files: parse_env_value(SESSION_MAX_FILES_ENV).unwrap_or(defaults.max_files),
1612 max_age_days: parse_env_value(SESSION_MAX_AGE_DAYS_ENV)
1613 .unwrap_or(defaults.max_age_days),
1614 max_total_size_bytes: parse_env_value::<u64>(SESSION_MAX_SIZE_MB_ENV)
1615 .map(|value| value.saturating_mul(BYTES_PER_MB))
1616 .unwrap_or(defaults.max_total_size_bytes),
1617 }
1618 }
1619}
1620
1621fn session_retention_limits() -> SessionRetentionLimits {
1622 SessionRetentionLimits::from_env()
1623}
1624
1625fn parse_env_value<T>(key: &str) -> Option<T>
1626where
1627 T: FromStr,
1628{
1629 read_env_var(key)?.trim().parse().ok()
1630}
1631
1632fn apply_session_retention_best_effort(sessions_dir: &Path) {
1633 if let Err(err) = apply_session_retention(sessions_dir) {
1634 tracing::warn!(
1635 sessions_dir = %sessions_dir.display(),
1636 error = %err,
1637 "Failed to prune session archives"
1638 );
1639 }
1640}
1641
1642fn apply_session_retention(sessions_dir: &Path) -> Result<()> {
1643 apply_session_retention_with_limits(sessions_dir, session_retention_limits())
1644}
1645
1646fn apply_session_retention_with_limits(
1647 sessions_dir: &Path,
1648 limits: SessionRetentionLimits,
1649) -> Result<()> {
1650 let mut entries = collect_session_entries(sessions_dir)?;
1651
1652 if entries.is_empty() {
1653 return Ok(());
1654 }
1655
1656 let now = SystemTime::now();
1657 let age_cutoff = if limits.max_age_days == 0 {
1658 now
1659 } else {
1660 now.checked_sub(Duration::from_secs(
1661 limits.max_age_days.saturating_mul(SECONDS_PER_DAY),
1662 ))
1663 .unwrap_or(UNIX_EPOCH)
1664 };
1665
1666 let (expired, retained): (Vec<_>, Vec<_>) = entries
1667 .into_iter()
1668 .partition(|entry| entry.modified <= age_cutoff);
1669 remove_session_files(expired);
1670 entries = retained;
1671
1672 entries.sort_by(|a, b| b.modified.cmp(&a.modified));
1673
1674 if limits.max_files > 0 && entries.len() > limits.max_files {
1675 let overflow = entries.split_off(limits.max_files);
1676 remove_session_files(overflow);
1677 }
1678
1679 if limits.max_total_size_bytes == 0 || entries.is_empty() {
1680 return Ok(());
1681 }
1682
1683 let mut total_size = 0u64;
1684 let mut keep_entries = Vec::with_capacity(entries.len());
1685 let mut size_overflow = Vec::new();
1686
1687 for entry in entries {
1688 let projected = total_size.saturating_add(entry.size);
1689 if keep_entries.is_empty() || projected <= limits.max_total_size_bytes {
1690 total_size = projected;
1691 keep_entries.push(entry);
1692 } else {
1693 size_overflow.push(entry);
1694 }
1695 }
1696
1697 remove_session_files(size_overflow);
1698 Ok(())
1699}
1700
1701fn collect_session_entries(sessions_dir: &Path) -> Result<Vec<SessionFileEntry>> {
1702 if !sessions_dir.exists() {
1703 return Ok(Vec::new());
1704 }
1705
1706 let mut entries = Vec::new();
1707 for entry in fs::read_dir(sessions_dir).with_context(|| {
1708 format!(
1709 "failed to read session directory for retention: {}",
1710 sessions_dir.display()
1711 )
1712 })? {
1713 let entry = match entry {
1714 Ok(value) => value,
1715 Err(err) => {
1716 tracing::warn!(
1717 sessions_dir = %sessions_dir.display(),
1718 error = %err,
1719 "Failed to read a session archive entry"
1720 );
1721 continue;
1722 }
1723 };
1724 let path = entry.path();
1725 if !is_session_file(&path) {
1726 continue;
1727 }
1728 let metadata = match entry.metadata() {
1729 Ok(value) => value,
1730 Err(err) => {
1731 tracing::warn!(
1732 path = %path.display(),
1733 error = %err,
1734 "Failed to read session archive metadata"
1735 );
1736 continue;
1737 }
1738 };
1739 if !metadata.is_file() {
1740 continue;
1741 }
1742 let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
1743 entries.push(SessionFileEntry {
1744 path,
1745 modified,
1746 size: metadata.len(),
1747 });
1748 }
1749
1750 Ok(entries)
1751}
1752
1753fn remove_session_files(entries: Vec<SessionFileEntry>) {
1754 for entry in entries {
1755 if let Err(err) = fs::remove_file(&entry.path) {
1756 tracing::warn!(
1757 path = %entry.path.display(),
1758 error = %err,
1759 "Failed to remove session archive"
1760 );
1761 }
1762 }
1763}
1764
1765fn truncate_preview(input: &str, max_chars: usize) -> String {
1766 vtcode_commons::formatting::truncate_within(input, max_chars, "…")
1767}
1768
1769fn compact_snapshot_to_max_bytes(
1770 mut snapshot: SessionSnapshot,
1771 max_bytes: usize,
1772) -> Result<SessionSnapshot> {
1773 if max_bytes == 0 {
1774 minimize_snapshot_payload(&mut snapshot);
1775 return Ok(snapshot);
1776 }
1777
1778 while serde_json::to_vec(&snapshot)?.len() > max_bytes {
1779 if trim_oldest_snapshot_entries(&mut snapshot) {
1780 continue;
1781 }
1782 if strip_snapshot_overhead(&mut snapshot) {
1783 continue;
1784 }
1785 if shrink_snapshot_strings(&mut snapshot) {
1786 continue;
1787 }
1788 break;
1789 }
1790
1791 if serde_json::to_vec(&snapshot)?.len() > max_bytes {
1792 minimize_snapshot_payload(&mut snapshot);
1793 let _ = shrink_snapshot_strings(&mut snapshot);
1794 }
1795
1796 Ok(snapshot)
1797}
1798
1799fn prepare_snapshot_for_write(snapshot: SessionSnapshot) -> Result<Option<SessionSnapshot>> {
1800 if !history_persistence_enabled() {
1801 return Ok(None);
1802 }
1803
1804 let max_bytes = session_history_settings().max_bytes;
1805 let snapshot = match max_bytes {
1806 Some(max_bytes) => compact_snapshot_to_max_bytes(snapshot, max_bytes)?,
1807 None => snapshot,
1808 };
1809
1810 Ok(Some(snapshot))
1811}
1812
1813fn minimize_snapshot_payload(snapshot: &mut SessionSnapshot) {
1814 snapshot.messages.clear();
1815 snapshot.transcript.clear();
1816 snapshot.distinct_tools.clear();
1817 snapshot.error_logs.clear();
1818 if let Some(progress) = snapshot.progress.as_mut() {
1819 progress.recent_messages.clear();
1820 progress.tool_summaries.clear();
1821 progress.token_usage = None;
1822 progress.max_context_tokens = None;
1823 progress.loaded_skills.clear();
1824 }
1825}
1826
1827fn trim_oldest_snapshot_entries(snapshot: &mut SessionSnapshot) -> bool {
1828 let mut changed = false;
1829
1830 if snapshot.messages.len() > 1 {
1831 snapshot.messages.remove(0);
1832 changed = true;
1833 }
1834
1835 if snapshot.transcript.len() > 1 {
1836 snapshot.transcript.remove(0);
1837 changed = true;
1838 }
1839
1840 if let Some(progress) = snapshot.progress.as_mut()
1841 && progress.recent_messages.len() > 1
1842 {
1843 progress.recent_messages.remove(0);
1844 changed = true;
1845 }
1846
1847 changed
1848}
1849
1850fn strip_snapshot_overhead(snapshot: &mut SessionSnapshot) -> bool {
1851 let mut changed = false;
1852
1853 if !snapshot.transcript.is_empty() {
1854 snapshot.transcript.clear();
1855 changed = true;
1856 }
1857 if !snapshot.distinct_tools.is_empty() {
1858 snapshot.distinct_tools.clear();
1859 changed = true;
1860 }
1861 if !snapshot.error_logs.is_empty() {
1862 snapshot.error_logs.clear();
1863 changed = true;
1864 }
1865
1866 if let Some(progress) = snapshot.progress.as_mut() {
1867 if !progress.tool_summaries.is_empty() {
1868 progress.tool_summaries.clear();
1869 changed = true;
1870 }
1871 if progress.token_usage.take().is_some() {
1872 changed = true;
1873 }
1874 if progress.max_context_tokens.take().is_some() {
1875 changed = true;
1876 }
1877 if !progress.loaded_skills.is_empty() {
1878 progress.loaded_skills.clear();
1879 changed = true;
1880 }
1881 }
1882
1883 changed
1884}
1885
1886fn shrink_snapshot_strings(snapshot: &mut SessionSnapshot) -> bool {
1887 let mut changed = shrink_snapshot_metadata(&mut snapshot.metadata);
1888
1889 for transcript in &mut snapshot.transcript {
1890 changed |= shrink_string(transcript);
1891 }
1892
1893 for message in &mut snapshot.messages {
1894 changed |= shrink_session_message(message);
1895 }
1896
1897 for error_log in &mut snapshot.error_logs {
1898 changed |= shrink_string(&mut error_log.message);
1899 }
1900
1901 if let Some(progress) = snapshot.progress.as_mut() {
1902 for message in &mut progress.recent_messages {
1903 changed |= shrink_session_message(message);
1904 }
1905 if let Some(token_usage) = progress.token_usage.as_mut() {
1906 changed |= shrink_string(token_usage);
1907 }
1908 }
1909
1910 changed
1911}
1912
1913fn shrink_session_message(message: &mut SessionMessage) -> bool {
1914 let mut changed = false;
1915 changed |= shrink_message_content(&mut message.content);
1916
1917 if let Some(reasoning) = message.reasoning.as_mut() {
1918 changed |= shrink_string(reasoning);
1919 }
1920 if message.reasoning_details.take().is_some() {
1921 changed = true;
1922 }
1923 if let Some(tool_call_id) = message.tool_call_id.as_mut() {
1924 changed |= shrink_string(tool_call_id);
1925 }
1926 if let Some(origin_tool) = message.origin_tool.as_mut() {
1927 changed |= shrink_string(origin_tool);
1928 }
1929 if let Some(tool_calls) = message.tool_calls.as_mut() {
1930 for tool_call in tool_calls.iter_mut() {
1931 changed |= shrink_string(&mut tool_call.id);
1932 changed |= shrink_string(&mut tool_call.call_type);
1933 if let Some(function) = tool_call.function.as_mut() {
1934 changed |= shrink_string(&mut function.name);
1935 changed |= shrink_string(&mut function.arguments);
1936 }
1937 if let Some(text) = tool_call.text.as_mut() {
1938 changed |= shrink_string(text);
1939 }
1940 if let Some(thought_signature) = tool_call.thought_signature.as_mut() {
1941 changed |= shrink_string(thought_signature);
1942 }
1943 }
1944 }
1945
1946 changed
1947}
1948
1949fn shrink_snapshot_metadata(metadata: &mut SessionArchiveMetadata) -> bool {
1950 let mut changed = false;
1951
1952 changed |= shrink_string(&mut metadata.workspace_label);
1953 changed |= shrink_string(&mut metadata.workspace_path);
1954 changed |= shrink_string(&mut metadata.model);
1955 changed |= shrink_string(&mut metadata.provider);
1956 changed |= shrink_string(&mut metadata.theme);
1957 changed |= shrink_string(&mut metadata.reasoning_effort);
1958 changed |= shrink_optional_string(&mut metadata.session_mode);
1959 changed |= shrink_optional_string(&mut metadata.debug_log_path);
1960 changed |= shrink_optional_string(&mut metadata.prompt_cache_lineage_id);
1961 changed |= shrink_optional_string(&mut metadata.external_thread_id);
1962 changed |= shrink_optional_string(&mut metadata.parent_session_id);
1963
1964 for skill in &mut metadata.loaded_skills {
1965 changed |= shrink_string(skill);
1966 }
1967
1968 if let Some(continuation) = metadata.continuation_metadata.as_mut() {
1969 if continuation.max_budget_usd_micros == Some(0) {
1970 continuation.max_budget_usd_micros = None;
1971 changed = true;
1972 }
1973 if continuation.actual_cost_usd_micros == Some(0) {
1974 continuation.actual_cost_usd_micros = None;
1975 changed = true;
1976 }
1977 if !continuation.summary_available
1978 && continuation.recommended_action
1979 == Some(SessionContinuationRecommendedAction::ContinueFromSummary)
1980 {
1981 continuation.recommended_action = None;
1982 changed = true;
1983 }
1984 }
1985
1986 changed
1987}
1988
1989fn shrink_message_content(content: &mut MessageContent) -> bool {
1990 match content {
1991 MessageContent::Text(text) => shrink_string(text),
1992 MessageContent::Parts(parts) => {
1993 let mut changed = false;
1994 for part in parts {
1995 changed |= match part {
1996 crate::llm::provider::ContentPart::Text { text } => shrink_string(text),
1997 crate::llm::provider::ContentPart::Image {
1998 data, mime_type, ..
1999 } => shrink_string(data) | shrink_string(mime_type),
2000 crate::llm::provider::ContentPart::File {
2001 filename,
2002 file_id,
2003 file_data,
2004 file_url,
2005 ..
2006 } => {
2007 shrink_optional_string(filename)
2008 | shrink_optional_string(file_id)
2009 | shrink_optional_string(file_data)
2010 | shrink_optional_string(file_url)
2011 }
2012 };
2013 }
2014 changed
2015 }
2016 }
2017}
2018
2019fn shrink_optional_string(value: &mut Option<String>) -> bool {
2020 value.as_mut().is_some_and(shrink_string)
2021}
2022
2023fn shrink_string(value: &mut String) -> bool {
2024 const MIN_RETAINED_CHARS: usize = 8;
2025 const TRUNCATION_MARKER: &str = "...";
2026
2027 if value.len() <= MIN_RETAINED_CHARS + TRUNCATION_MARKER.len() {
2028 return false;
2029 }
2030
2031 let keep_len = (value.len() / 2).max(MIN_RETAINED_CHARS);
2032 let prefix_len = keep_len.saturating_sub(TRUNCATION_MARKER.len());
2033 value.truncate(prefix_len);
2034 value.push_str(TRUNCATION_MARKER);
2035 true
2036}
2037
2038fn sanitize_component(value: &str) -> String {
2039 let mut normalized = String::new();
2040 let mut last_was_separator = false;
2041 for ch in value.chars() {
2042 if ch.is_ascii_alphanumeric() {
2043 normalized.push(ch.to_ascii_lowercase());
2044 last_was_separator = false;
2045 } else if matches!(ch, '-' | '_') {
2046 if !last_was_separator {
2047 normalized.push(ch);
2048 last_was_separator = true;
2049 }
2050 } else if !last_was_separator {
2051 normalized.push('-');
2052 last_was_separator = true;
2053 }
2054 }
2055
2056 let trimmed = normalized.trim_matches(|c| c == '-' || c == '_');
2057 if trimmed.is_empty() {
2058 "workspace".to_owned()
2059 } else {
2060 trimmed.to_owned()
2061 }
2062}
2063
2064fn is_session_file(path: &Path) -> bool {
2065 matches!(
2066 path.extension().and_then(|ext| ext.to_str()),
2067 Some(ext)
2068 if ext.eq_ignore_ascii_case(SESSION_FILE_EXTENSION)
2069 || ext.eq_ignore_ascii_case("jsonl")
2070 || ext.eq_ignore_ascii_case("log")
2071 )
2072}
2073
2074#[cfg(test)]
2075#[path = "session_archive_tests.rs"]
2076mod tests;