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