Skip to main content

vtcode_core/utils/
session_archive.rs

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    /// Names of skills loaded in this session
154    #[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    /// Set loaded skills for this session
275    pub fn with_loaded_skills(mut self, skills: Vec<String>) -> Self {
276        self.loaded_skills = skills;
277        self
278    }
279
280    /// Set debug log path associated with this archive.
281    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    // Keep sparse metadata boxed so long histories don't pay large inline Option<T> costs.
357    #[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    // SessionProgress is heavy and frequently absent in final snapshots.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub progress: Option<Box<SessionProgress>>,
520    /// ERROR-level log entries captured during the session for post-mortem debugging.
521    #[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    /// Names of skills loaded at checkpoint time
537    #[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
787/// Reserve a unique session archive identifier for the current process.
788///
789/// The returned identifier is the JSON file stem (without `.json`) and can be reused
790/// to create an archive and pair external artifacts (for example debug logs).
791pub 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    /// Create a session archive using an explicitly reserved session identifier.
1206    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    /// Reopen an existing archive file so follow-up runs can overwrite the snapshot in place.
1217    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    /// Update loaded skills in the archive metadata
1302    pub fn set_loaded_skills(&mut self, skills: Vec<String>) {
1303        self.metadata.loaded_skills = skills;
1304    }
1305
1306    /// Update continuation metadata in the archive metadata.
1307    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    /// Create a forked session from an existing session snapshot
1319    ///
1320    /// This creates a new session archive that inherits metadata from the source
1321    /// session but operates independently. The forked session will have a new
1322    /// archive file with a custom suffix if provided.
1323    ///
1324    /// # Arguments
1325    /// * `source_snapshot` - The snapshot of the session to fork from
1326    /// * `custom_suffix` - Optional custom suffix for the new session ID
1327    ///
1328    /// # Returns
1329    /// A new SessionArchive instance for the forked session
1330    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    // Collect all session file paths first
1373    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    // Process session files in parallel for better performance with large archives
1390    // Batch processing to avoid overwhelming the system with too many concurrent tasks
1391    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        // Collect results from this batch
1409        for task in tasks {
1410            if let Ok(Some(listing)) = task.await {
1411                all_listings.push(listing);
1412            }
1413        }
1414    }
1415
1416    // Sort and limit results
1417    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
1425/// Find a session archive by its identifier (file stem) without needing to list all sessions.
1426pub 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
1448/// Find a saved session by ID or name for TUI `/resume` command.
1449///
1450/// Searches both the session identifier (UUID or file stem) and matches
1451/// against recent sessions. Returns a descriptive error message when no
1452/// matching session is found.
1453pub async fn find_session_by_id_or_name(id_or_name: &str) -> Result<SessionListing> {
1454    // First try exact identifier match (UUID or file stem)
1455    if let Some(listing) = find_session_by_identifier(id_or_name).await? {
1456        return Ok(listing);
1457    }
1458
1459    // Fall back to searching recent sessions by identifier substring
1460    let listings = list_recent_sessions(200).await?;
1461    let normalized_query = id_or_name.to_lowercase();
1462
1463    for listing in &listings {
1464        // Match against the file stem (session identifier)
1465        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, // Simple matching score (e.g., term frequency or just 1.0)
1487}
1488
1489/// Search for a query string across recent sessions.
1490/// Returns a list of `SearchResult` sorted by relevance (or recency).
1491pub 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(&regex::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;