Skip to main content

chasm/
storage.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! VS Code storage (SQLite database) operations
4
5use crate::error::{CsmError, Result};
6use crate::models::{
7    ChatRequest, ChatSession, ChatSessionIndex, ChatSessionIndexEntry, ChatSessionTiming,
8};
9use crate::workspace::{get_empty_window_sessions_path, get_workspace_storage_path};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14use sysinfo::System;
15
16/// Regex to match any Unicode escape sequence (valid or not)
17static UNICODE_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\u[0-9a-fA-F]{4}").unwrap());
18
19/// VS Code session format version - helps identify which parsing strategy to use
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VsCodeSessionFormat {
22    /// Legacy JSON format (VS Code < 1.109.0)
23    /// Single JSON object with ChatSession structure
24    LegacyJson,
25    /// JSONL format (VS Code >= 1.109.0, January 2026+)
26    /// JSON Lines with event sourcing: kind 0 (initial), kind 1 (delta), kind 2 (requests)
27    JsonLines,
28}
29
30/// Session schema version - tracks the internal structure version
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum SessionSchemaVersion {
33    /// Version 1 - Original format (basic fields)
34    V1 = 1,
35    /// Version 2 - Added more metadata fields
36    V2 = 2,
37    /// Version 3 - Current format with full request/response structure
38    V3 = 3,
39    /// Unknown version
40    Unknown = 0,
41}
42
43impl SessionSchemaVersion {
44    /// Create from version number
45    pub fn from_version(v: u32) -> Self {
46        match v {
47            1 => Self::V1,
48            2 => Self::V2,
49            3 => Self::V3,
50            _ => Self::Unknown,
51        }
52    }
53
54    /// Get version number
55    pub fn version_number(&self) -> u32 {
56        match self {
57            Self::V1 => 1,
58            Self::V2 => 2,
59            Self::V3 => 3,
60            Self::Unknown => 0,
61        }
62    }
63
64    /// Get description
65    pub fn description(&self) -> &'static str {
66        match self {
67            Self::V1 => "v1 (basic)",
68            Self::V2 => "v2 (extended metadata)",
69            Self::V3 => "v3 (full structure)",
70            Self::Unknown => "unknown",
71        }
72    }
73}
74
75impl std::fmt::Display for SessionSchemaVersion {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        write!(f, "{}", self.description())
78    }
79}
80
81/// Result of session format detection
82#[derive(Debug, Clone)]
83pub struct SessionFormatInfo {
84    /// File format (JSON or JSONL)
85    pub format: VsCodeSessionFormat,
86    /// Schema version detected from content
87    pub schema_version: SessionSchemaVersion,
88    /// Confidence level (0.0 - 1.0)
89    pub confidence: f32,
90    /// Detection method used
91    pub detection_method: &'static str,
92}
93
94impl VsCodeSessionFormat {
95    /// Detect format from file path (by extension)
96    pub fn from_path(path: &Path) -> Self {
97        match path.extension().and_then(|e| e.to_str()) {
98            Some("jsonl") => Self::JsonLines,
99            _ => Self::LegacyJson,
100        }
101    }
102
103    /// Detect format from content by analyzing structure
104    pub fn from_content(content: &str) -> Self {
105        let trimmed = content.trim();
106
107        // JSONL: Multiple lines starting with { or first line has {"kind":
108        if trimmed.starts_with("{\"kind\":") || trimmed.starts_with("{ \"kind\":") {
109            return Self::JsonLines;
110        }
111
112        // Count lines that look like JSON objects
113        let mut json_object_lines = 0;
114        let mut total_non_empty_lines = 0;
115
116        for line in trimmed.lines().take(10) {
117            let line = line.trim();
118            if line.is_empty() {
119                continue;
120            }
121            total_non_empty_lines += 1;
122
123            // Check if line is a JSON object with "kind" field (JSONL marker)
124            if line.starts_with('{') && line.contains("\"kind\"") {
125                json_object_lines += 1;
126            }
127        }
128
129        // If multiple lines look like JSONL entries, it's JSONL
130        if json_object_lines >= 2
131            || (json_object_lines == 1 && total_non_empty_lines == 1 && trimmed.contains("\n{"))
132        {
133            return Self::JsonLines;
134        }
135
136        // Check if it's a single JSON object (legacy format)
137        if trimmed.starts_with('{') && trimmed.ends_with('}') {
138            // Look for ChatSession structure markers
139            if trimmed.contains("\"sessionId\"")
140                || trimmed.contains("\"creationDate\"")
141                || trimmed.contains("\"requests\"")
142            {
143                return Self::LegacyJson;
144            }
145        }
146
147        // Default to legacy JSON if unclear
148        Self::LegacyJson
149    }
150
151    /// Get minimum VS Code version that uses this format
152    pub fn min_vscode_version(&self) -> &'static str {
153        match self {
154            Self::LegacyJson => "1.0.0",
155            Self::JsonLines => "1.109.0",
156        }
157    }
158
159    /// Get human-readable format description
160    pub fn description(&self) -> &'static str {
161        match self {
162            Self::LegacyJson => "Legacy JSON (single object)",
163            Self::JsonLines => "JSON Lines (event-sourced, VS Code 1.109.0+)",
164        }
165    }
166
167    /// Get short format name
168    pub fn short_name(&self) -> &'static str {
169        match self {
170            Self::LegacyJson => "json",
171            Self::JsonLines => "jsonl",
172        }
173    }
174}
175
176impl std::fmt::Display for VsCodeSessionFormat {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        write!(f, "{}", self.description())
179    }
180}
181
182/// Sanitize JSON content by replacing lone surrogates with replacement character.
183/// VS Code sometimes writes invalid JSON with lone Unicode surrogates (e.g., \udde0).
184fn sanitize_json_unicode(content: &str) -> String {
185    // Process all \uXXXX sequences and fix lone surrogates
186    let mut result = String::with_capacity(content.len());
187    let mut last_end = 0;
188
189    // Collect all matches first to avoid borrowing issues
190    let matches: Vec<_> = UNICODE_ESCAPE_RE.find_iter(content).collect();
191
192    for (i, mat) in matches.iter().enumerate() {
193        let start = mat.start();
194        let end = mat.end();
195
196        // Add content before this match
197        result.push_str(&content[last_end..start]);
198
199        // Parse the hex value from the match itself (always ASCII \uXXXX)
200        let hex_str = &mat.as_str()[2..]; // Skip the \u prefix
201        if let Ok(code_point) = u16::from_str_radix(hex_str, 16) {
202            // Check if it's a high surrogate (D800-DBFF)
203            if (0xD800..=0xDBFF).contains(&code_point) {
204                // Check if next match is immediately following and is a low surrogate
205                let is_valid_pair = if let Some(next_mat) = matches.get(i + 1) {
206                    // Must be immediately adjacent (no gap)
207                    if next_mat.start() == end {
208                        let next_hex = &next_mat.as_str()[2..];
209                        if let Ok(next_cp) = u16::from_str_radix(next_hex, 16) {
210                            (0xDC00..=0xDFFF).contains(&next_cp)
211                        } else {
212                            false
213                        }
214                    } else {
215                        false
216                    }
217                } else {
218                    false
219                };
220
221                if is_valid_pair {
222                    // Valid surrogate pair, keep the high surrogate
223                    result.push_str(mat.as_str());
224                } else {
225                    // Lone high surrogate - replace with replacement char
226                    result.push_str("\\uFFFD");
227                }
228            }
229            // Check if it's a low surrogate (DC00-DFFF)
230            else if (0xDC00..=0xDFFF).contains(&code_point) {
231                // Check if previous match was immediately before and was a high surrogate
232                let is_valid_pair = if i > 0 {
233                    if let Some(prev_mat) = matches.get(i - 1) {
234                        // Must be immediately adjacent (no gap)
235                        if prev_mat.end() == start {
236                            let prev_hex = &prev_mat.as_str()[2..];
237                            if let Ok(prev_cp) = u16::from_str_radix(prev_hex, 16) {
238                                (0xD800..=0xDBFF).contains(&prev_cp)
239                            } else {
240                                false
241                            }
242                        } else {
243                            false
244                        }
245                    } else {
246                        false
247                    }
248                } else {
249                    false
250                };
251
252                if is_valid_pair {
253                    // Part of valid surrogate pair, keep it
254                    result.push_str(mat.as_str());
255                } else {
256                    // Lone low surrogate - replace with replacement char
257                    result.push_str("\\uFFFD");
258                }
259            }
260            // Normal code point
261            else {
262                result.push_str(mat.as_str());
263            }
264        } else {
265            // Invalid hex - keep as is
266            result.push_str(mat.as_str());
267        }
268        last_end = end;
269    }
270
271    // Add remaining content
272    result.push_str(&content[last_end..]);
273    result
274}
275
276/// Try to parse JSON, sanitizing invalid Unicode if needed
277pub fn parse_session_json(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
278    match serde_json::from_str::<ChatSession>(content) {
279        Ok(session) => Ok(session),
280        Err(e) => {
281            // If parsing fails due to Unicode issue, try sanitizing
282            if e.to_string().contains("surrogate") || e.to_string().contains("escape") {
283                let sanitized = sanitize_json_unicode(content);
284                serde_json::from_str::<ChatSession>(&sanitized)
285            } else {
286                Err(e)
287            }
288        }
289    }
290}
291
292/// JSONL entry kinds for VS Code 1.109.0+ session format
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294enum JsonlKind {
295    /// Initial session state (kind: 0)
296    Initial = 0,
297    /// Delta update to specific keys (kind: 1)  
298    Delta = 1,
299    /// Full requests array update (kind: 2)
300    RequestsUpdate = 2,
301}
302
303/// Parse a JSONL (JSON Lines) session file (VS Code 1.109.0+ format)
304/// Each line is a JSON object with 'kind' field indicating the type:
305/// - kind 0: Initial session metadata with 'v' containing ChatSession-like structure
306/// - kind 1: Delta update with 'k' (keys path) and 'v' (value)
307/// - kind 2: Full requests array update with 'k' and 'v'
308pub fn parse_session_jsonl(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
309    // Pre-process: split concatenated JSON objects that lack newline separators
310    let content = split_concatenated_jsonl(content);
311
312    let mut session = ChatSession {
313        version: 3,
314        session_id: None,
315        creation_date: 0,
316        last_message_date: 0,
317        is_imported: false,
318        initial_location: "panel".to_string(),
319        custom_title: None,
320        requester_username: None,
321        requester_avatar_icon_uri: None,
322        responder_username: None,
323        responder_avatar_icon_uri: None,
324        requests: Vec::new(),
325    };
326
327    for line in content.lines() {
328        let line = line.trim();
329        if line.is_empty() {
330            continue;
331        }
332
333        // Parse each line as a JSON object
334        let entry: serde_json::Value = match serde_json::from_str(line) {
335            Ok(v) => v,
336            Err(_) => {
337                // Try sanitizing Unicode
338                let sanitized = sanitize_json_unicode(line);
339                serde_json::from_str(&sanitized)?
340            }
341        };
342
343        let kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(0);
344
345        match kind {
346            0 => {
347                // Initial state - 'v' contains the session metadata
348                if let Some(v) = entry.get("v") {
349                    // Parse version
350                    if let Some(version) = v.get("version").and_then(|x| x.as_u64()) {
351                        session.version = version as u32;
352                    }
353                    // Parse session ID
354                    if let Some(sid) = v.get("sessionId").and_then(|x| x.as_str()) {
355                        session.session_id = Some(sid.to_string());
356                    }
357                    // Parse creation date
358                    if let Some(cd) = v.get("creationDate").and_then(|x| x.as_i64()) {
359                        session.creation_date = cd;
360                    }
361                    // Parse initial location
362                    if let Some(loc) = v.get("initialLocation").and_then(|x| x.as_str()) {
363                        session.initial_location = loc.to_string();
364                    }
365                    // Parse responder username
366                    if let Some(ru) = v.get("responderUsername").and_then(|x| x.as_str()) {
367                        session.responder_username = Some(ru.to_string());
368                    }
369                    // Parse custom title
370                    if let Some(title) = v.get("customTitle").and_then(|x| x.as_str()) {
371                        session.custom_title = Some(title.to_string());
372                    }
373                    // Parse hasPendingEdits as imported marker
374                    if let Some(imported) = v.get("isImported").and_then(|x| x.as_bool()) {
375                        session.is_imported = imported;
376                    }
377                    // Parse requests array if present
378                    if let Some(requests) = v.get("requests") {
379                        if let Ok(reqs) =
380                            serde_json::from_value::<Vec<ChatRequest>>(requests.clone())
381                        {
382                            session.requests = reqs;
383                            // Compute last_message_date from the latest request timestamp
384                            if let Some(latest_ts) =
385                                session.requests.iter().filter_map(|r| r.timestamp).max()
386                            {
387                                session.last_message_date = latest_ts;
388                            }
389                        }
390                    }
391                    // Fall back to creationDate if no request timestamps found
392                    if session.last_message_date == 0 {
393                        session.last_message_date = session.creation_date;
394                    }
395                }
396            }
397            1 => {
398                // Delta update - 'k' is array of key path, 'v' is the value
399                if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
400                    if let Some(keys_arr) = keys.as_array() {
401                        // Handle top-level session keys
402                        if keys_arr.len() == 1 {
403                            if let Some(key) = keys_arr[0].as_str() {
404                                match key {
405                                    "customTitle" => {
406                                        if let Some(title) = value.as_str() {
407                                            session.custom_title = Some(title.to_string());
408                                        }
409                                    }
410                                    "lastMessageDate" => {
411                                        if let Some(date) = value.as_i64() {
412                                            session.last_message_date = date;
413                                        }
414                                    }
415                                    "hasPendingEdits" | "isImported" => {
416                                        // Session-level boolean updates, safe to ignore for now
417                                    }
418                                    _ => {} // Ignore unknown keys
419                                }
420                            }
421                        }
422                        // Handle nested request field updates: ["requests", idx, field]
423                        else if keys_arr.len() == 3 {
424                            if let (Some("requests"), Some(idx), Some(field)) = (
425                                keys_arr[0].as_str(),
426                                keys_arr[1].as_u64().map(|i| i as usize),
427                                keys_arr[2].as_str(),
428                            ) {
429                                if idx < session.requests.len() {
430                                    match field {
431                                        "response" => {
432                                            session.requests[idx].response = Some(value.clone());
433                                        }
434                                        "result" => {
435                                            session.requests[idx].result = Some(value.clone());
436                                        }
437                                        "followups" => {
438                                            session.requests[idx].followups =
439                                                serde_json::from_value(value.clone()).ok();
440                                        }
441                                        "isCanceled" => {
442                                            session.requests[idx].is_canceled = value.as_bool();
443                                        }
444                                        "contentReferences" => {
445                                            session.requests[idx].content_references =
446                                                serde_json::from_value(value.clone()).ok();
447                                        }
448                                        "codeCitations" => {
449                                            session.requests[idx].code_citations =
450                                                serde_json::from_value(value.clone()).ok();
451                                        }
452                                        "modelState" | "modelId" | "agent" | "variableData" => {
453                                            // Known request fields - update as generic Value
454                                            // modelState tracks the request lifecycle
455                                        }
456                                        _ => {} // Ignore unknown request fields
457                                    }
458                                }
459                            }
460                        }
461                    }
462                }
463            }
464            2 => {
465                // Array append operation - 'k' is the key path, 'v' is array of items to append
466                if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
467                    if let Some(keys_arr) = keys.as_array() {
468                        // Top-level requests append: k=["requests"], v=[new_request]
469                        if keys_arr.len() == 1 {
470                            if let Some("requests") = keys_arr[0].as_str() {
471                                if let Some(items) = value.as_array() {
472                                    for item in items {
473                                        if let Ok(req) =
474                                            serde_json::from_value::<ChatRequest>(item.clone())
475                                        {
476                                            session.requests.push(req);
477                                        }
478                                    }
479                                    // Update last message date from latest request
480                                    if let Some(last_req) = session.requests.last() {
481                                        if let Some(ts) = last_req.timestamp {
482                                            session.last_message_date = ts;
483                                        }
484                                    }
485                                }
486                            }
487                        }
488                        // Nested array append: k=["requests", idx, "response"], v=[parts]
489                        // These are response streaming chunks - we can safely ignore them
490                        // since the final response is captured via kind:1 updates
491                    }
492                }
493            }
494            _ => {} // Unknown kind, skip
495        }
496    }
497
498    Ok(session)
499}
500
501/// Check if a file extension indicates a session file (.json or .jsonl)
502pub fn is_session_file_extension(ext: &std::ffi::OsStr) -> bool {
503    ext == "json" || ext == "jsonl"
504}
505
506/// Detect session format and version from content
507pub fn detect_session_format(content: &str) -> SessionFormatInfo {
508    let format = VsCodeSessionFormat::from_content(content);
509    let trimmed = content.trim();
510
511    // Detect schema version based on format
512    let (schema_version, confidence, method) = match format {
513        VsCodeSessionFormat::JsonLines => {
514            // For JSONL, check the first line's "v" object for version
515            if let Some(first_line) = trimmed.lines().next() {
516                if let Ok(entry) = serde_json::from_str::<serde_json::Value>(first_line) {
517                    if let Some(v) = entry.get("v") {
518                        if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) {
519                            (
520                                SessionSchemaVersion::from_version(ver as u32),
521                                0.95,
522                                "jsonl-version-field",
523                            )
524                        } else {
525                            // No version field, likely v3 (current default)
526                            (SessionSchemaVersion::V3, 0.7, "jsonl-default")
527                        }
528                    } else {
529                        (SessionSchemaVersion::V3, 0.6, "jsonl-no-v-field")
530                    }
531                } else {
532                    (SessionSchemaVersion::Unknown, 0.3, "jsonl-parse-error")
533                }
534            } else {
535                (SessionSchemaVersion::Unknown, 0.2, "jsonl-empty")
536            }
537        }
538        VsCodeSessionFormat::LegacyJson => {
539            // For JSON, directly check the version field
540            if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
541                if let Some(ver) = json.get("version").and_then(|x| x.as_u64()) {
542                    (
543                        SessionSchemaVersion::from_version(ver as u32),
544                        0.95,
545                        "json-version-field",
546                    )
547                } else {
548                    // Infer from structure
549                    if json.get("requests").is_some() && json.get("sessionId").is_some() {
550                        (SessionSchemaVersion::V3, 0.8, "json-structure-inference")
551                    } else if json.get("messages").is_some() {
552                        (SessionSchemaVersion::V1, 0.7, "json-legacy-structure")
553                    } else {
554                        (SessionSchemaVersion::Unknown, 0.4, "json-unknown-structure")
555                    }
556                }
557            } else {
558                // Try sanitizing and parsing again
559                let sanitized = sanitize_json_unicode(trimmed);
560                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&sanitized) {
561                    if let Some(ver) = json.get("version").and_then(|x| x.as_u64()) {
562                        (
563                            SessionSchemaVersion::from_version(ver as u32),
564                            0.9,
565                            "json-version-after-sanitize",
566                        )
567                    } else {
568                        (SessionSchemaVersion::V3, 0.6, "json-default-after-sanitize")
569                    }
570                } else {
571                    (SessionSchemaVersion::Unknown, 0.2, "json-parse-error")
572                }
573            }
574        }
575    };
576
577    SessionFormatInfo {
578        format,
579        schema_version,
580        confidence,
581        detection_method: method,
582    }
583}
584
585/// Parse session content with automatic format detection
586pub fn parse_session_auto(
587    content: &str,
588) -> std::result::Result<(ChatSession, SessionFormatInfo), serde_json::Error> {
589    let format_info = detect_session_format(content);
590
591    let session = match format_info.format {
592        VsCodeSessionFormat::JsonLines => parse_session_jsonl(content)?,
593        VsCodeSessionFormat::LegacyJson => parse_session_json(content)?,
594    };
595
596    Ok((session, format_info))
597}
598
599/// Parse a session file, automatically detecting format from content (not just extension)
600pub fn parse_session_file(path: &Path) -> std::result::Result<ChatSession, serde_json::Error> {
601    let content = std::fs::read_to_string(path)
602        .map_err(|e| serde_json::Error::io(std::io::Error::other(e.to_string())))?;
603
604    // Use content-based auto-detection
605    let (session, _format_info) = parse_session_auto(&content)?;
606    Ok(session)
607}
608
609/// Get the path to the workspace storage database
610pub fn get_workspace_storage_db(workspace_id: &str) -> Result<PathBuf> {
611    let storage_path = get_workspace_storage_path()?;
612    Ok(storage_path.join(workspace_id).join("state.vscdb"))
613}
614
615/// Read the chat session index from VS Code storage
616pub fn read_chat_session_index(db_path: &Path) -> Result<ChatSessionIndex> {
617    let conn = Connection::open(db_path)?;
618
619    let result: std::result::Result<String, rusqlite::Error> = conn.query_row(
620        "SELECT value FROM ItemTable WHERE key = ?",
621        ["chat.ChatSessionStore.index"],
622        |row| row.get(0),
623    );
624
625    match result {
626        Ok(json_str) => serde_json::from_str(&json_str)
627            .map_err(|e| CsmError::InvalidSessionFormat(e.to_string())),
628        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ChatSessionIndex::default()),
629        Err(e) => Err(CsmError::SqliteError(e)),
630    }
631}
632
633/// Write the chat session index to VS Code storage
634pub fn write_chat_session_index(db_path: &Path, index: &ChatSessionIndex) -> Result<()> {
635    let conn = Connection::open(db_path)?;
636    let json_str = serde_json::to_string(index)?;
637
638    // Check if the key exists
639    let exists: bool = conn.query_row(
640        "SELECT COUNT(*) > 0 FROM ItemTable WHERE key = ?",
641        ["chat.ChatSessionStore.index"],
642        |row| row.get(0),
643    )?;
644
645    if exists {
646        conn.execute(
647            "UPDATE ItemTable SET value = ? WHERE key = ?",
648            [&json_str, "chat.ChatSessionStore.index"],
649        )?;
650    } else {
651        conn.execute(
652            "INSERT INTO ItemTable (key, value) VALUES (?, ?)",
653            ["chat.ChatSessionStore.index", &json_str],
654        )?;
655    }
656
657    Ok(())
658}
659
660/// Add a session to the VS Code index
661pub fn add_session_to_index(
662    db_path: &Path,
663    session_id: &str,
664    title: &str,
665    last_message_date_ms: i64,
666    _is_imported: bool,
667    initial_location: &str,
668    is_empty: bool,
669) -> Result<()> {
670    let mut index = read_chat_session_index(db_path)?;
671
672    index.entries.insert(
673        session_id.to_string(),
674        ChatSessionIndexEntry {
675            session_id: session_id.to_string(),
676            title: title.to_string(),
677            last_message_date: last_message_date_ms,
678            timing: Some(ChatSessionTiming {
679                created: last_message_date_ms,
680                last_request_started: Some(last_message_date_ms),
681                last_request_ended: Some(last_message_date_ms),
682            }),
683            last_response_state: 1, // ResponseModelState.Complete
684            initial_location: initial_location.to_string(),
685            is_empty,
686        },
687    );
688
689    write_chat_session_index(db_path, &index)
690}
691
692/// Remove a session from the VS Code index
693#[allow(dead_code)]
694pub fn remove_session_from_index(db_path: &Path, session_id: &str) -> Result<bool> {
695    let mut index = read_chat_session_index(db_path)?;
696    let removed = index.entries.remove(session_id).is_some();
697    if removed {
698        write_chat_session_index(db_path, &index)?;
699    }
700    Ok(removed)
701}
702
703/// Sync the VS Code index with sessions on disk (remove stale entries, add missing ones)
704/// When both .json and .jsonl exist for the same session ID, prefers .jsonl.
705pub fn sync_session_index(
706    workspace_id: &str,
707    chat_sessions_dir: &Path,
708    force: bool,
709) -> Result<(usize, usize)> {
710    let db_path = get_workspace_storage_db(workspace_id)?;
711
712    if !db_path.exists() {
713        return Err(CsmError::WorkspaceNotFound(format!(
714            "Database not found: {}",
715            db_path.display()
716        )));
717    }
718
719    // Check if VS Code is running
720    if !force && is_vscode_running() {
721        return Err(CsmError::VSCodeRunning);
722    }
723
724    // Get current index
725    let mut index = read_chat_session_index(&db_path)?;
726
727    // Get session files on disk
728    let mut files_on_disk: std::collections::HashSet<String> = std::collections::HashSet::new();
729    if chat_sessions_dir.exists() {
730        for entry in std::fs::read_dir(chat_sessions_dir)? {
731            let entry = entry?;
732            let path = entry.path();
733            if path
734                .extension()
735                .map(is_session_file_extension)
736                .unwrap_or(false)
737            {
738                if let Some(stem) = path.file_stem() {
739                    files_on_disk.insert(stem.to_string_lossy().to_string());
740                }
741            }
742        }
743    }
744
745    // Remove stale entries (in index but not on disk)
746    let stale_ids: Vec<String> = index
747        .entries
748        .keys()
749        .filter(|id| !files_on_disk.contains(*id))
750        .cloned()
751        .collect();
752
753    let removed = stale_ids.len();
754    for id in &stale_ids {
755        index.entries.remove(id);
756    }
757
758    // Add/update sessions from disk
759    // Collect files, preferring .jsonl over .json for the same session ID
760    let mut session_files: std::collections::HashMap<String, PathBuf> =
761        std::collections::HashMap::new();
762    for entry in std::fs::read_dir(chat_sessions_dir)? {
763        let entry = entry?;
764        let path = entry.path();
765        if path
766            .extension()
767            .map(is_session_file_extension)
768            .unwrap_or(false)
769        {
770            if let Some(stem) = path.file_stem() {
771                let stem_str = stem.to_string_lossy().to_string();
772                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
773                // Insert if no entry yet, or if this is .jsonl (preferred over .json)
774                if !session_files.contains_key(&stem_str) || is_jsonl {
775                    session_files.insert(stem_str, path);
776                }
777            }
778        }
779    }
780
781    let mut added = 0;
782    for (_, path) in &session_files {
783        if let Ok(session) = parse_session_file(path) {
784            let session_id = session.session_id.clone().unwrap_or_else(|| {
785                path.file_stem()
786                    .map(|s| s.to_string_lossy().to_string())
787                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
788            });
789
790            let title = session.title();
791            let is_empty = session.is_empty();
792            let last_message_date = session.last_message_date;
793            let initial_location = session.initial_location.clone();
794
795            index.entries.insert(
796                session_id.clone(),
797                ChatSessionIndexEntry {
798                    session_id,
799                    title,
800                    last_message_date,
801                    timing: Some(ChatSessionTiming {
802                        created: session.creation_date,
803                        last_request_started: Some(last_message_date),
804                        last_request_ended: Some(last_message_date),
805                    }),
806                    last_response_state: 1, // ResponseModelState.Complete
807                    initial_location,
808                    is_empty,
809                },
810            );
811            added += 1;
812        }
813    }
814
815    // Write the synced index
816    write_chat_session_index(&db_path, &index)?;
817
818    Ok((added, removed))
819}
820
821/// Register all sessions from a directory into the VS Code index
822pub fn register_all_sessions_from_directory(
823    workspace_id: &str,
824    chat_sessions_dir: &Path,
825    force: bool,
826) -> Result<usize> {
827    let db_path = get_workspace_storage_db(workspace_id)?;
828
829    if !db_path.exists() {
830        return Err(CsmError::WorkspaceNotFound(format!(
831            "Database not found: {}",
832            db_path.display()
833        )));
834    }
835
836    // Check if VS Code is running
837    if !force && is_vscode_running() {
838        return Err(CsmError::VSCodeRunning);
839    }
840
841    // Use sync to ensure index matches disk
842    let (added, removed) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
843
844    // Print individual session info
845    for entry in std::fs::read_dir(chat_sessions_dir)? {
846        let entry = entry?;
847        let path = entry.path();
848
849        if path
850            .extension()
851            .map(is_session_file_extension)
852            .unwrap_or(false)
853        {
854            if let Ok(session) = parse_session_file(&path) {
855                let session_id = session.session_id.clone().unwrap_or_else(|| {
856                    path.file_stem()
857                        .map(|s| s.to_string_lossy().to_string())
858                        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
859                });
860
861                let title = session.title();
862
863                println!(
864                    "[OK] Registered: {} ({}...)",
865                    title,
866                    &session_id[..12.min(session_id.len())]
867                );
868            }
869        }
870    }
871
872    if removed > 0 {
873        println!("[OK] Removed {} stale index entries", removed);
874    }
875
876    Ok(added)
877}
878
879/// Check if VS Code is currently running
880pub fn is_vscode_running() -> bool {
881    let mut sys = System::new();
882    sys.refresh_processes();
883
884    for process in sys.processes().values() {
885        let name = process.name().to_lowercase();
886        if name.contains("code") && !name.contains("codec") {
887            return true;
888        }
889    }
890
891    false
892}
893
894/// Close VS Code gracefully and wait for it to exit.
895/// Returns the list of workspace folders that were open (for reopening).
896pub fn close_vscode_and_wait(timeout_secs: u64) -> Result<()> {
897    use sysinfo::{ProcessRefreshKind, RefreshKind, Signal};
898
899    if !is_vscode_running() {
900        return Ok(());
901    }
902
903    // Send SIGTERM (graceful close) to all Code processes
904    let mut sys = System::new_with_specifics(
905        RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
906    );
907    sys.refresh_processes();
908
909    let mut signaled = 0u32;
910    for (pid, process) in sys.processes() {
911        let name = process.name().to_lowercase();
912        if name.contains("code") && !name.contains("codec") {
913            // On Windows, kill() sends TerminateProcess; there's no graceful
914            // SIGTERM equivalent via sysinfo. But the main electron process
915            // handles WM_CLOSE. We use the `taskkill` approach on Windows for
916            // a graceful close.
917            #[cfg(windows)]
918            {
919                let _ = std::process::Command::new("taskkill")
920                    .args(["/PID", &pid.as_u32().to_string()])
921                    .stdout(std::process::Stdio::null())
922                    .stderr(std::process::Stdio::null())
923                    .status();
924                signaled += 1;
925            }
926            #[cfg(not(windows))]
927            {
928                if process.kill_with(Signal::Term).unwrap_or(false) {
929                    signaled += 1;
930                }
931            }
932        }
933    }
934
935    if signaled == 0 {
936        return Ok(());
937    }
938
939    // Wait for all Code processes to exit
940    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
941    loop {
942        std::thread::sleep(std::time::Duration::from_millis(500));
943        if !is_vscode_running() {
944            // Extra wait for file locks to release
945            std::thread::sleep(std::time::Duration::from_secs(1));
946            return Ok(());
947        }
948        if std::time::Instant::now() >= deadline {
949            // Force kill remaining processes
950            let mut sys2 = System::new_with_specifics(
951                RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
952            );
953            sys2.refresh_processes();
954            for (_pid, process) in sys2.processes() {
955                let name = process.name().to_lowercase();
956                if name.contains("code") && !name.contains("codec") {
957                    process.kill();
958                }
959            }
960            std::thread::sleep(std::time::Duration::from_secs(1));
961            return Ok(());
962        }
963    }
964}
965
966/// Reopen VS Code, optionally at a specific path.
967pub fn reopen_vscode(project_path: Option<&str>) -> Result<()> {
968    let mut cmd = std::process::Command::new("code");
969    if let Some(path) = project_path {
970        cmd.arg(path);
971    }
972    cmd.stdout(std::process::Stdio::null())
973        .stderr(std::process::Stdio::null())
974        .spawn()?;
975    Ok(())
976}
977
978/// Backup workspace sessions to a timestamped directory
979pub fn backup_workspace_sessions(workspace_dir: &Path) -> Result<Option<PathBuf>> {
980    let chat_sessions_dir = workspace_dir.join("chatSessions");
981
982    if !chat_sessions_dir.exists() {
983        return Ok(None);
984    }
985
986    let timestamp = std::time::SystemTime::now()
987        .duration_since(std::time::UNIX_EPOCH)
988        .unwrap()
989        .as_secs();
990
991    let backup_dir = workspace_dir.join(format!("chatSessions-backup-{}", timestamp));
992
993    // Copy directory recursively
994    copy_dir_all(&chat_sessions_dir, &backup_dir)?;
995
996    Ok(Some(backup_dir))
997}
998
999/// Recursively copy a directory
1000fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
1001    std::fs::create_dir_all(dst)?;
1002
1003    for entry in std::fs::read_dir(src)? {
1004        let entry = entry?;
1005        let src_path = entry.path();
1006        let dst_path = dst.join(entry.file_name());
1007
1008        if src_path.is_dir() {
1009            copy_dir_all(&src_path, &dst_path)?;
1010        } else {
1011            std::fs::copy(&src_path, &dst_path)?;
1012        }
1013    }
1014
1015    Ok(())
1016}
1017
1018// =============================================================================
1019// Empty Window Sessions (ALL SESSIONS)
1020// =============================================================================
1021
1022/// Read all empty window chat sessions (not tied to any workspace)
1023/// These appear in VS Code's "ALL SESSIONS" panel
1024pub fn read_empty_window_sessions() -> Result<Vec<ChatSession>> {
1025    let sessions_path = get_empty_window_sessions_path()?;
1026
1027    if !sessions_path.exists() {
1028        return Ok(Vec::new());
1029    }
1030
1031    let mut sessions = Vec::new();
1032
1033    for entry in std::fs::read_dir(&sessions_path)? {
1034        let entry = entry?;
1035        let path = entry.path();
1036
1037        if path.extension().is_some_and(is_session_file_extension) {
1038            if let Ok(session) = parse_session_file(&path) {
1039                sessions.push(session);
1040            }
1041        }
1042    }
1043
1044    // Sort by last message date (most recent first)
1045    sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
1046
1047    Ok(sessions)
1048}
1049
1050/// Get a specific empty window session by ID
1051#[allow(dead_code)]
1052pub fn get_empty_window_session(session_id: &str) -> Result<Option<ChatSession>> {
1053    let sessions_path = get_empty_window_sessions_path()?;
1054    let session_path = sessions_path.join(format!("{}.json", session_id));
1055
1056    if !session_path.exists() {
1057        return Ok(None);
1058    }
1059
1060    let content = std::fs::read_to_string(&session_path)?;
1061    let session: ChatSession = serde_json::from_str(&content)
1062        .map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
1063
1064    Ok(Some(session))
1065}
1066
1067/// Write an empty window session
1068#[allow(dead_code)]
1069pub fn write_empty_window_session(session: &ChatSession) -> Result<PathBuf> {
1070    let sessions_path = get_empty_window_sessions_path()?;
1071
1072    // Create directory if it doesn't exist
1073    std::fs::create_dir_all(&sessions_path)?;
1074
1075    let session_id = session.session_id.as_deref().unwrap_or("unknown");
1076    let session_path = sessions_path.join(format!("{}.json", session_id));
1077    let content = serde_json::to_string_pretty(session)?;
1078    std::fs::write(&session_path, content)?;
1079
1080    Ok(session_path)
1081}
1082
1083/// Delete an empty window session
1084#[allow(dead_code)]
1085pub fn delete_empty_window_session(session_id: &str) -> Result<bool> {
1086    let sessions_path = get_empty_window_sessions_path()?;
1087    let session_path = sessions_path.join(format!("{}.json", session_id));
1088
1089    if session_path.exists() {
1090        std::fs::remove_file(&session_path)?;
1091        Ok(true)
1092    } else {
1093        Ok(false)
1094    }
1095}
1096
1097/// Count empty window sessions
1098pub fn count_empty_window_sessions() -> Result<usize> {
1099    let sessions_path = get_empty_window_sessions_path()?;
1100
1101    if !sessions_path.exists() {
1102        return Ok(0);
1103    }
1104
1105    let count = std::fs::read_dir(&sessions_path)?
1106        .filter_map(|e| e.ok())
1107        .filter(|e| e.path().extension().is_some_and(is_session_file_extension))
1108        .count();
1109
1110    Ok(count)
1111}
1112
1113/// Compact a JSONL session file by replaying all operations into a single kind:0 snapshot.
1114/// This works at the raw JSON level, preserving all fields VS Code expects.
1115/// Returns the path to the compacted file.
1116///
1117/// Handles a common corruption pattern where VS Code appends delta operations
1118/// to line 0 without newline separators (e.g., `}{"kind":1,...}{"kind":2,...}`).
1119pub fn compact_session_jsonl(path: &Path) -> Result<PathBuf> {
1120    let content = std::fs::read_to_string(path).map_err(|e| {
1121        CsmError::InvalidSessionFormat(format!("Failed to read {}: {}", path.display(), e))
1122    })?;
1123
1124    // Pre-process: split concatenated JSON objects that lack newline separators.
1125    // VS Code sometimes appends delta ops to line 0 without a \n, producing:
1126    //   {"kind":0,"v":{...}}{"kind":1,...}{"kind":2,...}\n{"kind":1,...}\n...
1127    // We fix this by inserting newlines at every `}{"kind":` boundary.
1128    let content = split_concatenated_jsonl(&content);
1129
1130    let mut lines = content.lines();
1131
1132    // First line must be kind:0 (initial snapshot)
1133    let first_line = lines
1134        .next()
1135        .ok_or_else(|| CsmError::InvalidSessionFormat("Empty JSONL file".to_string()))?;
1136
1137    let first_entry: serde_json::Value = match serde_json::from_str(first_line.trim()) {
1138        Ok(v) => v,
1139        Err(_) => {
1140            // Try sanitizing Unicode (lone surrogates, etc.)
1141            let sanitized = sanitize_json_unicode(first_line.trim());
1142            serde_json::from_str(&sanitized).map_err(|e| {
1143                CsmError::InvalidSessionFormat(format!("Invalid JSON on line 1: {}", e))
1144            })?
1145        }
1146    };
1147
1148    let kind = first_entry
1149        .get("kind")
1150        .and_then(|k| k.as_u64())
1151        .unwrap_or(99);
1152    if kind != 0 {
1153        return Err(CsmError::InvalidSessionFormat(
1154            "First JSONL line must be kind:0".to_string(),
1155        ));
1156    }
1157
1158    // Extract the session state from the "v" field
1159    let mut state = first_entry
1160        .get("v")
1161        .cloned()
1162        .ok_or_else(|| CsmError::InvalidSessionFormat("kind:0 missing 'v' field".to_string()))?;
1163
1164    // Replay all subsequent operations
1165    for line in lines {
1166        let line = line.trim();
1167        if line.is_empty() {
1168            continue;
1169        }
1170
1171        let entry: serde_json::Value = match serde_json::from_str(line) {
1172            Ok(v) => v,
1173            Err(_) => continue, // Skip malformed lines
1174        };
1175
1176        let op_kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(99);
1177
1178        match op_kind {
1179            1 => {
1180                // Delta update: k=["path","to","field"], v=value
1181                if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
1182                    if let Some(keys_arr) = keys.as_array() {
1183                        apply_delta(&mut state, keys_arr, value.clone());
1184                    }
1185                }
1186            }
1187            2 => {
1188                // Array append: k=["path","to","array"], v=[items]
1189                if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
1190                    if let Some(keys_arr) = keys.as_array() {
1191                        apply_append(&mut state, keys_arr, value.clone());
1192                    }
1193                }
1194            }
1195            _ => {} // Skip unknown kinds
1196        }
1197    }
1198
1199    // Inject any missing fields that VS Code's latest format requires
1200    let session_id = path
1201        .file_stem()
1202        .and_then(|s| s.to_str())
1203        .map(|s| s.to_string());
1204    ensure_vscode_compat_fields(&mut state, session_id.as_deref());
1205
1206    // Write the compacted file: single kind:0 line with the final state
1207    let compact_entry = serde_json::json!({"kind": 0, "v": state});
1208    let compact_content = serde_json::to_string(&compact_entry)
1209        .map_err(|e| CsmError::InvalidSessionFormat(format!("Failed to serialize: {}", e)))?;
1210
1211    // Backup the original file
1212    let backup_path = path.with_extension("jsonl.bak");
1213    std::fs::rename(path, &backup_path)?;
1214
1215    // Write the compacted file
1216    std::fs::write(path, &compact_content)?;
1217
1218    Ok(backup_path)
1219}
1220
1221/// Split concatenated JSON objects in JSONL content that lack newline separators.
1222///
1223/// VS Code sometimes appends delta operations (kind:1, kind:2) onto the end of
1224/// a JSONL line without inserting a newline first. This produces invalid JSONL like:
1225///   `{"kind":0,"v":{...}}{"kind":1,...}{"kind":2,...}`
1226///
1227/// This function inserts newlines at every `}{"kind":` boundary to restore valid JSONL.
1228/// The pattern `}{"kind":` cannot appear inside JSON string values because `{"kind":`
1229/// would need to be escaped as `{\"kind\":` within a JSON string.
1230pub fn split_concatenated_jsonl(content: &str) -> String {
1231    // Fast path: if content has no concatenated objects, return as-is
1232    if !content.contains("}{\"kind\":") {
1233        return content.to_string();
1234    }
1235
1236    content.replace("}{\"kind\":", "}\n{\"kind\":")
1237}
1238
1239/// Apply a delta update (kind:1) to a JSON value at the given key path.
1240fn apply_delta(root: &mut serde_json::Value, keys: &[serde_json::Value], value: serde_json::Value) {
1241    if keys.is_empty() {
1242        return;
1243    }
1244
1245    // Navigate to the parent
1246    let mut current = root;
1247    for key in &keys[..keys.len() - 1] {
1248        if let Some(k) = key.as_str() {
1249            if !current.get(k).is_some() {
1250                current[k] = serde_json::Value::Object(serde_json::Map::new());
1251            }
1252            current = &mut current[k];
1253        } else if let Some(idx) = key.as_u64() {
1254            if let Some(arr) = current.as_array_mut() {
1255                if (idx as usize) < arr.len() {
1256                    current = &mut arr[idx as usize];
1257                } else {
1258                    return; // Index out of bounds
1259                }
1260            } else {
1261                return;
1262            }
1263        }
1264    }
1265
1266    // Set the final key
1267    if let Some(last_key) = keys.last() {
1268        if let Some(k) = last_key.as_str() {
1269            current[k] = value;
1270        } else if let Some(idx) = last_key.as_u64() {
1271            if let Some(arr) = current.as_array_mut() {
1272                if (idx as usize) < arr.len() {
1273                    arr[idx as usize] = value;
1274                }
1275            }
1276        }
1277    }
1278}
1279
1280/// Apply an array append operation (kind:2) to a JSON value at the given key path.
1281fn apply_append(
1282    root: &mut serde_json::Value,
1283    keys: &[serde_json::Value],
1284    items: serde_json::Value,
1285) {
1286    if keys.is_empty() {
1287        return;
1288    }
1289
1290    // Navigate to the target array
1291    let mut current = root;
1292    for key in keys {
1293        if let Some(k) = key.as_str() {
1294            if !current.get(k).is_some() {
1295                current[k] = serde_json::json!([]);
1296            }
1297            current = &mut current[k];
1298        } else if let Some(idx) = key.as_u64() {
1299            if let Some(arr) = current.as_array_mut() {
1300                if (idx as usize) < arr.len() {
1301                    current = &mut arr[idx as usize];
1302                } else {
1303                    return;
1304                }
1305            } else {
1306                return;
1307            }
1308        }
1309    }
1310
1311    // Append items to the target array
1312    if let (Some(target_arr), Some(new_items)) = (current.as_array_mut(), items.as_array()) {
1313        target_arr.extend(new_items.iter().cloned());
1314    }
1315}
1316
1317/// Ensure a JSONL `kind:0` snapshot's `v` object has all fields required by
1318/// VS Code's latest session format (1.109.0+ / version 3). Missing fields are
1319/// injected with sensible defaults so sessions load reliably after recovery,
1320/// conversion, or compaction.
1321///
1322/// Required fields that VS Code now expects:
1323/// - `version` (u32, default 3)
1324/// - `sessionId` (string, extracted from filename or generated)
1325/// - `responderUsername` (string, default "GitHub Copilot")
1326/// - `hasPendingEdits` (bool, default false)
1327/// - `pendingRequests` (array, default [])
1328/// - `inputState` (object with mode, attachments, etc.)
1329pub fn ensure_vscode_compat_fields(state: &mut serde_json::Value, session_id: Option<&str>) {
1330    if let Some(obj) = state.as_object_mut() {
1331        // version
1332        if !obj.contains_key("version") {
1333            obj.insert("version".to_string(), serde_json::json!(3));
1334        }
1335
1336        // sessionId — use provided ID, or try to read from existing field
1337        if !obj.contains_key("sessionId") {
1338            if let Some(id) = session_id {
1339                obj.insert("sessionId".to_string(), serde_json::json!(id));
1340            }
1341        }
1342
1343        // responderUsername
1344        if !obj.contains_key("responderUsername") {
1345            obj.insert(
1346                "responderUsername".to_string(),
1347                serde_json::json!("GitHub Copilot"),
1348            );
1349        }
1350
1351        // hasPendingEdits — always false for recovered/compacted sessions
1352        if !obj.contains_key("hasPendingEdits") {
1353            obj.insert("hasPendingEdits".to_string(), serde_json::json!(false));
1354        }
1355
1356        // pendingRequests — always empty for recovered/compacted sessions
1357        if !obj.contains_key("pendingRequests") {
1358            obj.insert(
1359                "pendingRequests".to_string(),
1360                serde_json::json!([]),
1361            );
1362        }
1363
1364        // inputState — VS Code expects this to exist with at least mode + attachments
1365        if !obj.contains_key("inputState") {
1366            obj.insert(
1367                "inputState".to_string(),
1368                serde_json::json!({
1369                    "attachments": [],
1370                    "mode": { "id": "agent", "kind": "agent" },
1371                    "inputText": "",
1372                    "selections": [],
1373                    "contrib": { "chatDynamicVariableModel": [] }
1374                }),
1375            );
1376        }
1377    }
1378}
1379
1380/// Repair workspace sessions: compact large JSONL files and fix the index.
1381/// Returns (compacted_count, index_fixed_count).
1382pub fn repair_workspace_sessions(
1383    workspace_id: &str,
1384    chat_sessions_dir: &Path,
1385    force: bool,
1386) -> Result<(usize, usize)> {
1387    let db_path = get_workspace_storage_db(workspace_id)?;
1388
1389    if !db_path.exists() {
1390        return Err(CsmError::WorkspaceNotFound(format!(
1391            "Database not found: {}",
1392            db_path.display()
1393        )));
1394    }
1395
1396    if !force && is_vscode_running() {
1397        return Err(CsmError::VSCodeRunning);
1398    }
1399
1400    let mut compacted = 0;
1401    let mut fields_fixed = 0;
1402
1403    if chat_sessions_dir.exists() {
1404        // Pass 1: Compact large JSONL files and fix missing fields
1405        for entry in std::fs::read_dir(chat_sessions_dir)? {
1406            let entry = entry?;
1407            let path = entry.path();
1408            if path.extension().is_some_and(|e| e == "jsonl") {
1409                let metadata = std::fs::metadata(&path)?;
1410                let size_mb = metadata.len() / (1024 * 1024);
1411
1412                let content = std::fs::read_to_string(&path)
1413                    .map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
1414                let line_count = content.lines().count();
1415
1416                if line_count > 1 {
1417                    // Compact multi-line JSONL (has operations to replay)
1418                    let stem = path
1419                        .file_stem()
1420                        .map(|s| s.to_string_lossy().to_string())
1421                        .unwrap_or_default();
1422                    println!(
1423                        "   Compacting {} ({} lines, {}MB)...",
1424                        stem, line_count, size_mb
1425                    );
1426
1427                    match compact_session_jsonl(&path) {
1428                        Ok(backup_path) => {
1429                            let new_size = std::fs::metadata(&path)
1430                                .map(|m| m.len() / (1024 * 1024))
1431                                .unwrap_or(0);
1432                            println!(
1433                                "   [OK] Compacted: {}MB -> {}MB (backup: {})",
1434                                size_mb,
1435                                new_size,
1436                                backup_path
1437                                    .file_name()
1438                                    .unwrap_or_default()
1439                                    .to_string_lossy()
1440                            );
1441                            compacted += 1;
1442                        }
1443                        Err(e) => {
1444                            println!("   [WARN] Failed to compact {}: {}", stem, e);
1445                        }
1446                    }
1447                } else {
1448                    // Single-line JSONL — check for missing VS Code fields
1449                    if let Some(first_line) = content.lines().next() {
1450                        if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(first_line) {
1451                            let is_kind_0 = obj
1452                                .get("kind")
1453                                .and_then(|k| k.as_u64())
1454                                .map(|k| k == 0)
1455                                .unwrap_or(false);
1456
1457                            if is_kind_0 {
1458                                if let Some(v) = obj.get("v") {
1459                                    let missing = !v.get("hasPendingEdits").is_some()
1460                                        || !v.get("pendingRequests").is_some()
1461                                        || !v.get("inputState").is_some()
1462                                        || !v.get("sessionId").is_some();
1463
1464                                    if missing {
1465                                        let session_id = path
1466                                            .file_stem()
1467                                            .and_then(|s| s.to_str())
1468                                            .map(|s| s.to_string());
1469                                        if let Some(v_mut) = obj.get_mut("v") {
1470                                            ensure_vscode_compat_fields(
1471                                                v_mut,
1472                                                session_id.as_deref(),
1473                                            );
1474                                        }
1475                                        let patched = serde_json::to_string(&obj).map_err(|e| {
1476                                            CsmError::InvalidSessionFormat(format!(
1477                                                "Failed to serialize: {}",
1478                                                e
1479                                            ))
1480                                        })?;
1481                                        std::fs::write(&path, &patched)?;
1482                                        let stem = path
1483                                            .file_stem()
1484                                            .map(|s| s.to_string_lossy().to_string())
1485                                            .unwrap_or_default();
1486                                        println!(
1487                                            "   [OK] Fixed missing VS Code fields: {}",
1488                                            stem
1489                                        );
1490                                        fields_fixed += 1;
1491                                    }
1492                                }
1493                            }
1494                        }
1495                    }
1496                }
1497            }
1498        }
1499    }
1500
1501    // Pass 2: Rebuild the index with correct metadata
1502    let (index_fixed, _) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
1503
1504    if fields_fixed > 0 {
1505        println!(
1506            "   [OK] Injected missing VS Code fields into {} session(s)",
1507            fields_fixed
1508        );
1509    }
1510
1511    Ok((compacted, index_fixed))
1512}