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