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