Skip to main content

chasm/
models.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Data models for VS Code chat sessions
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// VS Code workspace information
10#[derive(Debug, Clone)]
11pub struct Workspace {
12    /// Workspace hash (folder name in workspaceStorage)
13    pub hash: String,
14    /// Associated project path
15    pub project_path: Option<String>,
16    /// Full path to workspace directory
17    pub workspace_path: std::path::PathBuf,
18    /// Path to chatSessions directory
19    pub chat_sessions_path: std::path::PathBuf,
20    /// Number of chat session files
21    pub chat_session_count: usize,
22    /// Whether chatSessions directory exists
23    pub has_chat_sessions: bool,
24    /// Last modified timestamp
25    #[allow(dead_code)]
26    pub last_modified: Option<DateTime<Utc>>,
27}
28
29/// VS Code workspace.json structure
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkspaceJson {
32    pub folder: Option<String>,
33}
34
35/// VS Code Chat Session (version 3 format)
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ChatSession {
39    /// Session format version
40    #[serde(default = "default_version")]
41    pub version: u32,
42
43    /// Unique session identifier (may not be present in file, use filename)
44    #[serde(default)]
45    pub session_id: Option<String>,
46
47    /// Creation timestamp (milliseconds)
48    #[serde(default)]
49    pub creation_date: i64,
50
51    /// Last message timestamp (milliseconds)
52    #[serde(default)]
53    pub last_message_date: i64,
54
55    /// Whether this session was imported
56    #[serde(default)]
57    pub is_imported: bool,
58
59    /// Initial location (panel, terminal, notebook, editor)
60    #[serde(default = "default_location")]
61    pub initial_location: String,
62
63    /// Custom title set by user
64    #[serde(default)]
65    pub custom_title: Option<String>,
66
67    /// Requester username
68    #[serde(default)]
69    pub requester_username: Option<String>,
70
71    /// Requester avatar URI
72    #[serde(default)]
73    pub requester_avatar_icon_uri: Option<serde_json::Value>,
74
75    /// Responder username
76    #[serde(default)]
77    pub responder_username: Option<String>,
78
79    /// Responder avatar URI
80    #[serde(default)]
81    pub responder_avatar_icon_uri: Option<serde_json::Value>,
82
83    /// Chat requests/messages
84    #[serde(default)]
85    pub requests: Vec<ChatRequest>,
86}
87
88impl ChatSession {
89    /// Collect all text content from the session (user messages and responses)
90    pub fn collect_all_text(&self) -> String {
91        self.requests
92            .iter()
93            .flat_map(|req| {
94                let mut texts = Vec::new();
95                if let Some(msg) = &req.message {
96                    if let Some(text) = &msg.text {
97                        texts.push(text.as_str());
98                    }
99                }
100                if let Some(resp) = &req.response {
101                    if let Some(result) = resp.get("result").and_then(|v| v.as_str()) {
102                        texts.push(result);
103                    }
104                }
105                texts
106            })
107            .collect::<Vec<_>>()
108            .join(" ")
109    }
110
111    /// Get user message texts
112    pub fn user_messages(&self) -> Vec<&str> {
113        self.requests
114            .iter()
115            .filter_map(|req| req.message.as_ref().and_then(|m| m.text.as_deref()))
116            .collect()
117    }
118
119    /// Get assistant response texts
120    pub fn assistant_responses(&self) -> Vec<String> {
121        self.requests
122            .iter()
123            .filter_map(|req| {
124                req.response.as_ref().and_then(|r| {
125                    r.get("result")
126                        .and_then(|v| v.as_str())
127                        .map(|s| s.to_string())
128                })
129            })
130            .collect()
131    }
132}
133
134fn default_version() -> u32 {
135    3
136}
137
138fn default_location() -> String {
139    "panel".to_string()
140}
141
142/// Default response state: Complete (1)
143fn default_response_state() -> u8 {
144    1
145}
146
147/// A single chat request (message + response)
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct ChatRequest {
151    /// Request timestamp (milliseconds)
152    #[serde(default)]
153    pub timestamp: Option<i64>,
154
155    /// The user's message
156    #[serde(default)]
157    pub message: Option<ChatMessage>,
158
159    /// The AI's response (complex structure - use Value for flexibility)
160    #[serde(default)]
161    pub response: Option<serde_json::Value>,
162
163    /// Variable data (context, files, etc.)
164    #[serde(default)]
165    pub variable_data: Option<serde_json::Value>,
166
167    /// Request ID
168    #[serde(default)]
169    pub request_id: Option<String>,
170
171    /// Response ID
172    #[serde(default)]
173    pub response_id: Option<String>,
174
175    /// Model ID
176    #[serde(default)]
177    pub model_id: Option<String>,
178
179    /// Agent information
180    #[serde(default)]
181    pub agent: Option<serde_json::Value>,
182
183    /// Result metadata
184    #[serde(default)]
185    pub result: Option<serde_json::Value>,
186
187    /// Follow-up suggestions
188    #[serde(default)]
189    pub followups: Option<Vec<serde_json::Value>>,
190
191    /// Whether canceled
192    #[serde(default)]
193    pub is_canceled: Option<bool>,
194
195    /// Content references
196    #[serde(default)]
197    pub content_references: Option<Vec<serde_json::Value>>,
198
199    /// Code citations
200    #[serde(default)]
201    pub code_citations: Option<Vec<serde_json::Value>>,
202
203    /// Response markdown info
204    #[serde(default)]
205    pub response_markdown_info: Option<Vec<serde_json::Value>>,
206
207    /// Source session for merged requests
208    #[serde(rename = "_sourceSession", skip_serializing_if = "Option::is_none")]
209    pub source_session: Option<String>,
210}
211
212/// User message in a chat request
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215pub struct ChatMessage {
216    /// Message text
217    #[serde(alias = "content")]
218    pub text: Option<String>,
219
220    /// Message parts (for complex messages)
221    #[serde(default)]
222    pub parts: Option<Vec<serde_json::Value>>,
223}
224
225impl ChatMessage {
226    /// Get the text content of this message
227    pub fn get_text(&self) -> String {
228        self.text.clone().unwrap_or_default()
229    }
230}
231
232/// AI response in a chat request
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235#[allow(dead_code)]
236pub struct ChatResponse {
237    /// Response text
238    #[serde(alias = "content")]
239    pub text: Option<String>,
240
241    /// Response parts
242    #[serde(default)]
243    pub parts: Option<Vec<serde_json::Value>>,
244
245    /// Result metadata
246    #[serde(default)]
247    pub result: Option<serde_json::Value>,
248}
249
250/// VS Code chat session index (stored in state.vscdb)
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ChatSessionIndex {
253    /// Index version
254    #[serde(default = "default_index_version")]
255    pub version: u32,
256
257    /// Session entries keyed by session ID
258    #[serde(default)]
259    pub entries: HashMap<String, ChatSessionIndexEntry>,
260}
261
262fn default_index_version() -> u32 {
263    1
264}
265
266impl Default for ChatSessionIndex {
267    fn default() -> Self {
268        Self {
269            version: 1,
270            entries: HashMap::new(),
271        }
272    }
273}
274
275/// Session timing information (VS Code 1.109+)
276/// Supports both old format (startTime/endTime) and new format (created/lastRequestStarted/lastRequestEnded)
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct ChatSessionTiming {
280    /// When the session was created (ms since epoch)
281    /// Old format used "startTime", new format uses "created"
282    #[serde(default, alias = "startTime")]
283    pub created: i64,
284
285    /// When the most recent request started (ms since epoch)
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub last_request_started: Option<i64>,
288
289    /// When the most recent request completed (ms since epoch)
290    /// Old format used "endTime", new format uses "lastRequestEnded"
291    #[serde(default, skip_serializing_if = "Option::is_none", alias = "endTime")]
292    pub last_request_ended: Option<i64>,
293}
294
295/// Entry in the chat session index
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct ChatSessionIndexEntry {
299    /// Session ID
300    pub session_id: String,
301
302    /// Session title
303    pub title: String,
304
305    /// Last message timestamp (milliseconds)
306    pub last_message_date: i64,
307
308    /// Session timing (VS Code 1.109+)
309    #[serde(default)]
310    pub timing: Option<ChatSessionTiming>,
311
312    /// Last response state: 0=Pending, 1=Complete, 2=Cancelled, 3=Failed, 4=NeedsInput
313    #[serde(default = "default_response_state")]
314    pub last_response_state: u8,
315
316    /// Initial location (panel, terminal, etc.)
317    #[serde(default = "default_location")]
318    pub initial_location: String,
319
320    /// Whether the session is empty
321    #[serde(default)]
322    pub is_empty: bool,
323
324    /// Whether the session was imported
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub is_imported: Option<bool>,
327
328    /// Whether the session has pending edits
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub has_pending_edits: Option<bool>,
331
332    /// Whether the session is from an external source
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub is_external: Option<bool>,
335}
336
337/// Session with its file path for internal processing
338#[derive(Debug, Clone)]
339pub struct SessionWithPath {
340    pub path: std::path::PathBuf,
341    pub session: ChatSession,
342}
343
344impl SessionWithPath {
345    /// Get the session ID from the session data or from the filename
346    #[allow(dead_code)]
347    pub fn get_session_id(&self) -> String {
348        self.session.session_id.clone().unwrap_or_else(|| {
349            self.path
350                .file_stem()
351                .map(|s| s.to_string_lossy().to_string())
352                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
353        })
354    }
355}
356
357impl ChatSession {
358    /// Get the session ID (from field or will need to be set from filename)
359    #[allow(dead_code)]
360    pub fn get_session_id(&self) -> String {
361        self.session_id
362            .clone()
363            .unwrap_or_else(|| "unknown".to_string())
364    }
365
366    /// Get the title for this session (from custom_title or first message)
367    pub fn title(&self) -> String {
368        // First try custom_title
369        if let Some(title) = &self.custom_title {
370            if !title.is_empty() {
371                return title.clone();
372            }
373        }
374
375        // Try to extract from first message
376        if let Some(first_req) = self.requests.first() {
377            if let Some(msg) = &first_req.message {
378                if let Some(text) = &msg.text {
379                    // Truncate to first 50 chars
380                    let title: String = text.chars().take(50).collect();
381                    if !title.is_empty() {
382                        if title.len() < text.len() {
383                            return format!("{}...", title);
384                        }
385                        return title;
386                    }
387                }
388            }
389        }
390
391        "Untitled".to_string()
392    }
393
394    /// Check if this session is empty
395    pub fn is_empty(&self) -> bool {
396        self.requests.is_empty()
397    }
398
399    /// Get the request count
400    pub fn request_count(&self) -> usize {
401        self.requests.len()
402    }
403
404    /// Get the timestamp range of requests
405    pub fn timestamp_range(&self) -> Option<(i64, i64)> {
406        if self.requests.is_empty() {
407            return None;
408        }
409
410        let timestamps: Vec<i64> = self.requests.iter().filter_map(|r| r.timestamp).collect();
411
412        if timestamps.is_empty() {
413            return None;
414        }
415
416        let min = *timestamps.iter().min().unwrap();
417        let max = *timestamps.iter().max().unwrap();
418        Some((min, max))
419    }
420}