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#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct ChatSessionTiming {
279    /// When the session was created (ms since epoch)
280    #[serde(default)]
281    pub created: i64,
282
283    /// When the most recent request started (ms since epoch)
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub last_request_started: Option<i64>,
286
287    /// When the most recent request completed (ms since epoch)
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub last_request_ended: Option<i64>,
290}
291
292/// Entry in the chat session index
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct ChatSessionIndexEntry {
296    /// Session ID
297    pub session_id: String,
298
299    /// Session title
300    pub title: String,
301
302    /// Last message timestamp (milliseconds)
303    pub last_message_date: i64,
304
305    /// Session timing (VS Code 1.109+)
306    #[serde(default)]
307    pub timing: Option<ChatSessionTiming>,
308
309    /// Last response state: 0=Pending, 1=Complete, 2=Cancelled, 3=Failed, 4=NeedsInput
310    #[serde(default = "default_response_state")]
311    pub last_response_state: u8,
312
313    /// Initial location (panel, terminal, etc.)
314    #[serde(default = "default_location")]
315    pub initial_location: String,
316
317    /// Whether the session is empty
318    #[serde(default)]
319    pub is_empty: bool,
320}
321
322/// Session with its file path for internal processing
323#[derive(Debug, Clone)]
324pub struct SessionWithPath {
325    pub path: std::path::PathBuf,
326    pub session: ChatSession,
327}
328
329impl SessionWithPath {
330    /// Get the session ID from the session data or from the filename
331    #[allow(dead_code)]
332    pub fn get_session_id(&self) -> String {
333        self.session.session_id.clone().unwrap_or_else(|| {
334            self.path
335                .file_stem()
336                .map(|s| s.to_string_lossy().to_string())
337                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
338        })
339    }
340}
341
342impl ChatSession {
343    /// Get the session ID (from field or will need to be set from filename)
344    #[allow(dead_code)]
345    pub fn get_session_id(&self) -> String {
346        self.session_id
347            .clone()
348            .unwrap_or_else(|| "unknown".to_string())
349    }
350
351    /// Get the title for this session (from custom_title or first message)
352    pub fn title(&self) -> String {
353        // First try custom_title
354        if let Some(title) = &self.custom_title {
355            if !title.is_empty() {
356                return title.clone();
357            }
358        }
359
360        // Try to extract from first message
361        if let Some(first_req) = self.requests.first() {
362            if let Some(msg) = &first_req.message {
363                if let Some(text) = &msg.text {
364                    // Truncate to first 50 chars
365                    let title: String = text.chars().take(50).collect();
366                    if !title.is_empty() {
367                        if title.len() < text.len() {
368                            return format!("{}...", title);
369                        }
370                        return title;
371                    }
372                }
373            }
374        }
375
376        "Untitled".to_string()
377    }
378
379    /// Check if this session is empty
380    pub fn is_empty(&self) -> bool {
381        self.requests.is_empty()
382    }
383
384    /// Get the request count
385    pub fn request_count(&self) -> usize {
386        self.requests.len()
387    }
388
389    /// Get the timestamp range of requests
390    pub fn timestamp_range(&self) -> Option<(i64, i64)> {
391        if self.requests.is_empty() {
392            return None;
393        }
394
395        let timestamps: Vec<i64> = self.requests.iter().filter_map(|r| r.timestamp).collect();
396
397        if timestamps.is_empty() {
398            return None;
399        }
400
401        let min = *timestamps.iter().min().unwrap();
402        let max = *timestamps.iter().max().unwrap();
403        Some((min, max))
404    }
405}