Skip to main content

chasm/commands/
inspect.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! `chasm inspect` — Low-level inspection of VS Code state databases, session
4//! indices, mementos, caches, and session file validity.
5
6use anyhow::Result;
7use colored::Colorize;
8use rusqlite::Connection;
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use crate::models::{ChatSessionIndexEntry, ChatSessionTiming};
13use crate::storage::{
14    cleanup_state_cache, detect_session_format, fix_session_memento, get_workspace_storage_db,
15    is_session_file_extension, parse_session_auto, parse_session_file, read_chat_session_index,
16    read_db_json, read_model_cache, read_state_cache, rebuild_model_cache,
17    session_id_from_resource_uri, session_resource_uri, write_chat_session_index,
18    VsCodeSessionFormat,
19};
20use crate::workspace::{find_workspace_by_path, get_workspace_storage_path};
21
22// ── Helpers ────────────────────────────────────────────────────────────────
23
24/// Resolve a project path (or cwd) to its workspace hash and state.vscdb path.
25fn resolve_workspace(path: Option<&str>) -> Result<(String, PathBuf)> {
26    let project_path = match path {
27        Some(p) => p.to_string(),
28        None => std::env::current_dir()?.to_string_lossy().to_string(),
29    };
30
31    let (ws_hash, _ws_dir, _folder) = find_workspace_by_path(&project_path)?
32        .ok_or_else(|| anyhow::anyhow!("No VS Code workspace found for path: {}", project_path))?;
33
34    let db_path = get_workspace_storage_db(&ws_hash)?;
35    if !db_path.exists() {
36        anyhow::bail!("state.vscdb not found at {}", db_path.display());
37    }
38
39    Ok((ws_hash, db_path))
40}
41
42/// Resolve by explicit workspace hash instead of project path.
43fn resolve_by_hash(hash: &str) -> Result<(String, PathBuf)> {
44    let db_path = get_workspace_storage_db(hash)?;
45    if !db_path.exists() {
46        anyhow::bail!("state.vscdb not found at {}", db_path.display());
47    }
48    Ok((hash.to_string(), db_path))
49}
50
51/// Format a millisecond epoch timestamp as human-readable UTC datetime.
52fn fmt_ts(ms: i64) -> String {
53    if ms == 0 {
54        return "(none)".to_string();
55    }
56    let secs = ms / 1000;
57    let nanos = ((ms % 1000) * 1_000_000) as u32;
58    match chrono::DateTime::from_timestamp(secs, nanos) {
59        Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
60        None => format!("{}ms", ms),
61    }
62}
63
64/// Get (ws_hash, db_path) from either --path or --workspace-id.
65fn resolve(path: Option<&str>, workspace_id: Option<&str>) -> Result<(String, PathBuf)> {
66    if let Some(hash) = workspace_id {
67        resolve_by_hash(hash)
68    } else {
69        resolve_workspace(path)
70    }
71}
72
73// ── Subcommands ────────────────────────────────────────────────────────────
74
75/// `chasm inspect index` — Show the ChatSessionStore index entries.
76pub fn inspect_index(path: Option<&str>, workspace_id: Option<&str>, json: bool) -> Result<()> {
77    let (ws_hash, db_path) = resolve(path, workspace_id)?;
78
79    if !json {
80        println!(
81            "\n  {} {} ({})\n",
82            "Workspace:".bold(),
83            ws_hash.cyan(),
84            db_path.display()
85        );
86    }
87
88    let index = read_chat_session_index(&db_path)?;
89
90    if json {
91        println!("{}", serde_json::to_string_pretty(&index)?);
92        return Ok(());
93    }
94
95    println!("  {} {}", "Index version:".bold(), index.version);
96    println!("  {} {}\n", "Entry count:".bold(), index.entries.len());
97
98    if index.entries.is_empty() {
99        println!("  (no sessions in index)");
100        return Ok(());
101    }
102
103    // Sort by lastMessageDate descending
104    let mut entries: Vec<_> = index.entries.iter().collect();
105    entries.sort_by(|a, b| b.1.last_message_date.cmp(&a.1.last_message_date));
106
107    for (id, entry) in &entries {
108        let title = if entry.title.len() > 60 {
109            format!("{}...", &entry.title[..57])
110        } else {
111            entry.title.clone()
112        };
113
114        println!("  {} {}", "ID:".bright_black(), id.yellow());
115        println!("    {} {}", "Title:".bright_black(), title);
116        println!(
117            "    {} {}",
118            "Last message:".bright_black(),
119            fmt_ts(entry.last_message_date)
120        );
121
122        if let Some(timing) = &entry.timing {
123            println!(
124                "    {} {}",
125                "Created:".bright_black(),
126                fmt_ts(timing.created)
127            );
128        }
129
130        let state_label = match entry.last_response_state {
131            0 => "Pending",
132            1 => "Complete",
133            2 => "Cancelled",
134            3 => "Failed",
135            4 => "NeedsInput",
136            _ => "Unknown",
137        };
138        println!(
139            "    {} {}  {} {}  {} {}",
140            "State:".bright_black(),
141            state_label,
142            "Empty:".bright_black(),
143            entry.is_empty,
144            "Location:".bright_black(),
145            entry.initial_location,
146        );
147
148        // Check if session file exists on disk
149        let storage_path = get_workspace_storage_path()?;
150        let chat_dir = storage_path.join(&ws_hash).join("chatSessions");
151        let jsonl_path = chat_dir.join(format!("{}.jsonl", id));
152        let json_path = chat_dir.join(format!("{}.json", id));
153
154        let file_status = if jsonl_path.exists() {
155            let size = std::fs::metadata(&jsonl_path).map(|m| m.len()).unwrap_or(0);
156            if size < 500 {
157                format!("{} ({} bytes)", ".jsonl STUB".red(), size)
158            } else {
159                format!("{} ({} bytes)", ".jsonl".green(), size)
160            }
161        } else if json_path.exists() {
162            let size = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
163            format!("{} ({} bytes)", ".json (legacy)".yellow(), size)
164        } else {
165            "MISSING".red().to_string()
166        };
167        println!("    {} {}", "File:".bright_black(), file_status);
168
169        // Check for backup file
170        let backup_jsonl = chat_dir.join(format!("{}.jsonl.backup", id));
171        let backup_json = chat_dir.join(format!("{}.json.backup", id));
172        if backup_jsonl.exists() {
173            let size = std::fs::metadata(&backup_jsonl)
174                .map(|m| m.len())
175                .unwrap_or(0);
176            println!(
177                "    {} .jsonl.backup ({} bytes)",
178                "Backup:".bright_black(),
179                size
180            );
181        } else if backup_json.exists() {
182            let size = std::fs::metadata(&backup_json)
183                .map(|m| m.len())
184                .unwrap_or(0);
185            println!(
186                "    {} .json.backup ({} bytes)",
187                "Backup:".bright_black(),
188                size
189            );
190        }
191
192        println!();
193    }
194
195    Ok(())
196}
197
198/// `chasm inspect memento` — Show session memento (input history and active session state).
199pub fn inspect_memento(path: Option<&str>, workspace_id: Option<&str>, json: bool) -> Result<()> {
200    let (ws_hash, db_path) = resolve(path, workspace_id)?;
201
202    if !json {
203        println!(
204            "\n  {} {} ({})\n",
205            "Workspace:".bold(),
206            ws_hash.cyan(),
207            db_path.display()
208        );
209    }
210
211    // Read memento/interactive-session (input history)
212    let history = read_db_json(&db_path, "memento/interactive-session")?;
213    // Read memento/interactive-session-view-copilot (active session state)
214    let view_state = read_db_json(&db_path, "memento/interactive-session-view-copilot")?;
215
216    if json {
217        let output = serde_json::json!({
218            "workspace": ws_hash,
219            "inputHistory": history,
220            "viewState": view_state,
221        });
222        println!("{}", serde_json::to_string_pretty(&output)?);
223        return Ok(());
224    }
225
226    // Input history
227    println!(
228        "  {}",
229        "Input History (memento/interactive-session)"
230            .bold()
231            .underline()
232    );
233    match &history {
234        Some(val) => {
235            if let Some(obj) = val.as_object() {
236                for (provider, data) in obj {
237                    println!("\n  {} {}", "Provider:".bright_black(), provider.cyan());
238                    if let Some(arr) = data.as_array() {
239                        println!("  {} {} entries", "Entries:".bright_black(), arr.len());
240                        // Show last 5 entries
241                        let show = arr.len().min(5);
242                        let start = arr.len() - show;
243                        if start > 0 {
244                            println!("  {} (showing last {})", "...".bright_black(), show);
245                        }
246                        for (i, entry) in arr[start..].iter().enumerate() {
247                            let text = entry
248                                .as_str()
249                                .or_else(|| entry.get("text").and_then(|t| t.as_str()))
250                                .unwrap_or("<complex>");
251                            let truncated: String = text.chars().take(80).collect();
252                            let suffix = if text.len() > 80 { "..." } else { "" };
253                            println!(
254                                "    {} {}{}",
255                                format!("[{}]", start + i + 1).bright_black(),
256                                truncated,
257                                suffix
258                            );
259                        }
260                    } else {
261                        println!("  {} {:?}", "Value:".bright_black(), data);
262                    }
263                }
264            } else {
265                println!("  {}", serde_json::to_string_pretty(val)?);
266            }
267        }
268        None => println!("  (not found)"),
269    }
270
271    // Active session view state
272    println!(
273        "\n\n  {}",
274        "Active Session State (memento/interactive-session-view-copilot)"
275            .bold()
276            .underline()
277    );
278    match &view_state {
279        Some(val) => {
280            if let Some(obj) = val.as_object() {
281                for (key, v) in obj {
282                    let display = match v {
283                        serde_json::Value::String(s) => {
284                            if s.len() > 80 {
285                                format!("{}...", &s[..77])
286                            } else {
287                                s.clone()
288                            }
289                        }
290                        other => {
291                            let s = serde_json::to_string(other).unwrap_or_default();
292                            if s.len() > 80 {
293                                format!("{}...", &s[..77])
294                            } else {
295                                s
296                            }
297                        }
298                    };
299                    println!("  {} {} = {}", "•".bright_black(), key.cyan(), display);
300                }
301            } else {
302                println!("  {}", serde_json::to_string_pretty(val)?);
303            }
304        }
305        None => println!("  (not found)"),
306    }
307
308    println!();
309    Ok(())
310}
311
312/// `chasm inspect cache` — Show agentSessions model & state caches.
313pub fn inspect_cache(path: Option<&str>, workspace_id: Option<&str>, json: bool) -> Result<()> {
314    let (ws_hash, db_path) = resolve(path, workspace_id)?;
315
316    if !json {
317        println!(
318            "\n  {} {} ({})\n",
319            "Workspace:".bold(),
320            ws_hash.cyan(),
321            db_path.display()
322        );
323    }
324
325    let model_cache = read_model_cache(&db_path)?;
326    let state_cache = read_state_cache(&db_path)?;
327
328    if json {
329        let output = serde_json::json!({
330            "workspace": ws_hash,
331            "modelCache": model_cache,
332            "stateCache": state_cache,
333        });
334        println!("{}", serde_json::to_string_pretty(&output)?);
335        return Ok(());
336    }
337
338    // Model cache
339    println!(
340        "  {} ({} entries)\n",
341        "Model Cache (agentSessions.model.cache)".bold().underline(),
342        model_cache.len()
343    );
344
345    for entry in &model_cache {
346        let session_id =
347            session_id_from_resource_uri(&entry.resource).unwrap_or_else(|| entry.resource.clone());
348
349        let title = if entry.label.len() > 60 {
350            format!("{}...", &entry.label[..57])
351        } else {
352            entry.label.clone()
353        };
354
355        let status_label = match entry.status {
356            0 => "Pending".yellow(),
357            1 => "Valid".green(),
358            2 => "Cancelled".red(),
359            _ => format!("Unknown({})", entry.status).red(),
360        };
361
362        println!("  {} {}", "ID:".bright_black(), session_id.yellow());
363        println!("    {} {}", "Title:".bright_black(), title);
364        println!("    {} {}", "Status:".bright_black(), status_label);
365        println!(
366            "    {} {}",
367            "Created:".bright_black(),
368            fmt_ts(entry.timing.created)
369        );
370        println!(
371            "    {} type={}, label={}, location={}, empty={}, external={}",
372            "Meta:".bright_black(),
373            entry.provider_type,
374            entry.provider_label,
375            entry.initial_location,
376            entry.is_empty,
377            entry.is_external,
378        );
379        println!(
380            "    {} {}",
381            "Last state:".bright_black(),
382            match entry.last_response_state {
383                0 => "Pending",
384                1 => "Complete",
385                2 => "Cancelled",
386                3 => "Failed",
387                4 => "NeedsInput",
388                _ => "Unknown",
389            }
390        );
391        println!();
392    }
393
394    // State cache
395    println!(
396        "\n  {} ({} entries)\n",
397        "State Cache (agentSessions.state.cache)".bold().underline(),
398        state_cache.len()
399    );
400
401    for entry in &state_cache {
402        let session_id =
403            session_id_from_resource_uri(&entry.resource).unwrap_or_else(|| entry.resource.clone());
404        let read_ts = entry
405            .read
406            .map(|r| fmt_ts(r))
407            .unwrap_or("(never)".to_string());
408
409        // Check if this session is in the model cache
410        let in_model = model_cache.iter().any(|m| m.resource == entry.resource);
411
412        let model_marker = if in_model {
413            "✓".green().to_string()
414        } else {
415            "✗".red().to_string()
416        };
417
418        println!(
419            "  {} {}  {} {}  {} {}",
420            "ID:".bright_black(),
421            session_id.yellow(),
422            "Read:".bright_black(),
423            read_ts,
424            "In model cache:".bright_black(),
425            model_marker,
426        );
427    }
428
429    println!();
430    Ok(())
431}
432
433/// `chasm inspect validate` — Validate session files on disk (format, size, parse).
434pub fn inspect_validate(path: Option<&str>, workspace_id: Option<&str>, json: bool) -> Result<()> {
435    let (ws_hash, db_path) = resolve(path, workspace_id)?;
436    let storage_path = get_workspace_storage_path()?;
437    let chat_dir = storage_path.join(&ws_hash).join("chatSessions");
438
439    if !json {
440        println!(
441            "\n  {} {} ({})\n",
442            "Workspace:".bold(),
443            ws_hash.cyan(),
444            db_path.display()
445        );
446    }
447
448    let index = read_chat_session_index(&db_path)?;
449
450    if !chat_dir.exists() {
451        if json {
452            println!(
453                "{}",
454                serde_json::to_string_pretty(&serde_json::json!({
455                    "workspace": ws_hash,
456                    "chatDir": chat_dir.display().to_string(),
457                    "exists": false,
458                    "sessions": [],
459                }))?
460            );
461        } else {
462            println!(
463                "  {} chatSessions directory does not exist: {}",
464                "✗".red(),
465                chat_dir.display()
466            );
467        }
468        return Ok(());
469    }
470
471    // Collect all session files on disk
472    let mut disk_files: std::collections::HashMap<String, PathBuf> =
473        std::collections::HashMap::new();
474    if let Ok(entries) = std::fs::read_dir(&chat_dir) {
475        for entry in entries.flatten() {
476            let p = entry.path();
477            if p.is_file() {
478                let ext = p
479                    .extension()
480                    .and_then(|e| e.to_str())
481                    .unwrap_or("")
482                    .to_string();
483                if ext == "json" || ext == "jsonl" {
484                    if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
485                        disk_files.insert(stem.to_string(), p.clone());
486                    }
487                }
488            }
489        }
490    }
491
492    let mut results: Vec<serde_json::Value> = Vec::new();
493    let mut pass_count = 0usize;
494    let mut warn_count = 0usize;
495    let mut fail_count = 0usize;
496
497    // Validate indexed sessions
498    let mut indexed_ids: Vec<String> = index.entries.keys().cloned().collect();
499    indexed_ids.sort();
500
501    for session_id in &indexed_ids {
502        let entry = &index.entries[session_id];
503        let file_path = disk_files.remove(session_id);
504
505        let (status, issues) =
506            validate_session_file(session_id, entry, file_path.as_deref(), &chat_dir);
507
508        match status {
509            ValidationStatus::Pass => pass_count += 1,
510            ValidationStatus::Warn => warn_count += 1,
511            ValidationStatus::Fail => fail_count += 1,
512        }
513
514        if json {
515            results.push(serde_json::json!({
516                "sessionId": session_id,
517                "title": entry.title,
518                "indexed": true,
519                "status": format!("{:?}", status),
520                "issues": issues,
521                "file": file_path.map(|p| p.display().to_string()),
522            }));
523        } else {
524            let icon = match status {
525                ValidationStatus::Pass => "✓".green(),
526                ValidationStatus::Warn => "⚠".yellow(),
527                ValidationStatus::Fail => "✗".red(),
528            };
529
530            let title = if entry.title.len() > 50 {
531                format!("{}...", &entry.title[..47])
532            } else {
533                entry.title.clone()
534            };
535
536            println!(
537                "  {} {} {}",
538                icon,
539                session_id.yellow(),
540                title.bright_black()
541            );
542            for issue in &issues {
543                println!("      {} {}", "→".bright_black(), issue);
544            }
545        }
546    }
547
548    // Check for orphaned files (on disk but not in index)
549    let mut orphaned: Vec<(String, PathBuf)> = disk_files.into_iter().collect();
550    orphaned.sort_by(|a, b| a.0.cmp(&b.0));
551
552    if !orphaned.is_empty() {
553        if !json {
554            println!(
555                "\n  {} ({} files)\n",
556                "Orphaned Files (on disk but not in index)"
557                    .bold()
558                    .underline(),
559                orphaned.len()
560            );
561        }
562
563        for (stem, file_path) in &orphaned {
564            let size = std::fs::metadata(file_path).map(|m| m.len()).unwrap_or(0);
565
566            let (format_str, parse_ok) = match std::fs::read_to_string(file_path) {
567                Ok(content) => {
568                    let info = detect_session_format(&content);
569                    let fmt = match info.format {
570                        VsCodeSessionFormat::JsonLines => "JSONL",
571                        VsCodeSessionFormat::LegacyJson => "JSON",
572                    };
573                    let parse_ok = parse_session_auto(&content).is_ok();
574                    (fmt.to_string(), parse_ok)
575                }
576                Err(_) => ("unreadable".to_string(), false),
577            };
578
579            warn_count += 1;
580
581            if json {
582                results.push(serde_json::json!({
583                    "sessionId": stem,
584                    "indexed": false,
585                    "status": "Warn",
586                    "issues": ["Orphaned: file exists but not in index"],
587                    "file": file_path.display().to_string(),
588                    "size": size,
589                    "format": format_str,
590                    "parseable": parse_ok,
591                }));
592            } else {
593                let parse_icon = if parse_ok {
594                    "parseable".green()
595                } else {
596                    "CORRUPT".red()
597                };
598                println!(
599                    "  {} {} {} ({} bytes, {}, {})",
600                    "⚠".yellow(),
601                    stem.yellow(),
602                    "orphaned".bright_black(),
603                    size,
604                    format_str,
605                    parse_icon,
606                );
607            }
608        }
609    }
610
611    // Check for backup and corrupt files
612    let mut special_files: Vec<(String, String, u64)> = Vec::new();
613    if let Ok(entries) = std::fs::read_dir(&chat_dir) {
614        for entry in entries.flatten() {
615            let p = entry.path();
616            if p.is_file() {
617                let name = p
618                    .file_name()
619                    .unwrap_or_default()
620                    .to_string_lossy()
621                    .to_string();
622                if name.ends_with(".backup") || name.ends_with(".corrupt") {
623                    let size = p.metadata().map(|m| m.len()).unwrap_or(0);
624                    special_files.push((name, p.display().to_string(), size));
625                }
626            }
627        }
628    }
629
630    if !special_files.is_empty() && !json {
631        special_files.sort();
632        println!(
633            "\n  {} ({} files)\n",
634            "Backup/Corrupt Files".bold().underline(),
635            special_files.len()
636        );
637        for (name, _path, size) in &special_files {
638            let icon = if name.ends_with(".backup") {
639                "📦".to_string()
640            } else {
641                "⚠".to_string()
642            };
643            println!("  {} {} ({} bytes)", icon, name.bright_black(), size);
644        }
645    }
646
647    // Summary
648    if json {
649        let output = serde_json::json!({
650            "workspace": ws_hash,
651            "chatDir": chat_dir.display().to_string(),
652            "exists": true,
653            "summary": {
654                "pass": pass_count,
655                "warn": warn_count,
656                "fail": fail_count,
657            },
658            "sessions": results,
659        });
660        println!("{}", serde_json::to_string_pretty(&output)?);
661    } else {
662        println!(
663            "\n  {} {} passed, {} warnings, {} failures\n",
664            "Summary:".bold(),
665            pass_count.to_string().green(),
666            warn_count.to_string().yellow(),
667            fail_count.to_string().red(),
668        );
669    }
670
671    Ok(())
672}
673
674#[derive(Debug)]
675enum ValidationStatus {
676    Pass,
677    Warn,
678    Fail,
679}
680
681fn validate_session_file(
682    session_id: &str,
683    _entry: &crate::models::ChatSessionIndexEntry,
684    file_path: Option<&Path>,
685    chat_dir: &Path,
686) -> (ValidationStatus, Vec<String>) {
687    let mut issues: Vec<String> = Vec::new();
688
689    // 1. Check file existence
690    let file_path = match file_path {
691        Some(p) => p.to_path_buf(),
692        None => {
693            // Try both extensions
694            let jsonl = chat_dir.join(format!("{}.jsonl", session_id));
695            let json = chat_dir.join(format!("{}.json", session_id));
696            if jsonl.exists() {
697                jsonl
698            } else if json.exists() {
699                json
700            } else {
701                issues.push("File MISSING: no .jsonl or .json found on disk".to_string());
702                return (ValidationStatus::Fail, issues);
703            }
704        }
705    };
706
707    // 2. Check file size
708    let size = match std::fs::metadata(&file_path) {
709        Ok(m) => m.len(),
710        Err(e) => {
711            issues.push(format!("Cannot read metadata: {}", e));
712            return (ValidationStatus::Fail, issues);
713        }
714    };
715
716    if size == 0 {
717        issues.push("File is EMPTY (0 bytes)".to_string());
718        return (ValidationStatus::Fail, issues);
719    }
720
721    if size < 500 {
722        issues.push(format!(
723            "File is a STUB ({} bytes) — likely replaced by VS Code shutdown",
724            size
725        ));
726        // Check if backup exists
727        let backup = PathBuf::from(format!("{}.backup", file_path.display()));
728        if backup.exists() {
729            let backup_size = std::fs::metadata(&backup).map(|m| m.len()).unwrap_or(0);
730            issues.push(format!(
731                "Backup exists ({} bytes) — recoverable with 'chasm register repair'",
732                backup_size
733            ));
734        }
735        return (ValidationStatus::Warn, issues);
736    }
737
738    // 3. Read and detect format
739    let content = match std::fs::read_to_string(&file_path) {
740        Ok(c) => c,
741        Err(e) => {
742            issues.push(format!("Cannot read file: {}", e));
743            return (ValidationStatus::Fail, issues);
744        }
745    };
746
747    let format_info = detect_session_format(&content);
748    let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
749
750    // Check extension vs content format mismatch
751    let expected_format = match ext {
752        "jsonl" => VsCodeSessionFormat::JsonLines,
753        "json" => VsCodeSessionFormat::LegacyJson,
754        _ => format_info.format,
755    };
756
757    if format_info.format != expected_format {
758        issues.push(format!(
759            "Format mismatch: extension is .{} but content is {:?}",
760            ext, format_info.format
761        ));
762    }
763
764    // 4. Try parsing
765    match parse_session_auto(&content) {
766        Ok((session, _info)) => {
767            let req_count = session.requests.len();
768            if req_count == 0 {
769                issues.push("Session parses OK but has 0 requests (empty)".to_string());
770            }
771
772            // Report format details
773            if format_info.confidence < 0.5 {
774                issues.push(format!(
775                    "Low confidence format detection ({:.0}%, method: {})",
776                    format_info.confidence * 100.0,
777                    format_info.detection_method
778                ));
779            }
780
781            if issues.is_empty() {
782                // All good — include brief info
783                issues.push(format!(
784                    "{:?}, {}, {} requests, {} bytes",
785                    format_info.format, format_info.schema_version, req_count, size,
786                ));
787                (ValidationStatus::Pass, issues)
788            } else {
789                (ValidationStatus::Warn, issues)
790            }
791        }
792        Err(e) => {
793            issues.push(format!("Parse FAILED: {}", e));
794            (ValidationStatus::Fail, issues)
795        }
796    }
797}
798
799/// `chasm inspect keys` — List all session-related keys in state.vscdb with sizes.
800pub fn inspect_keys(
801    path: Option<&str>,
802    workspace_id: Option<&str>,
803    all: bool,
804    json: bool,
805) -> Result<()> {
806    let (ws_hash, db_path) = resolve(path, workspace_id)?;
807
808    let conn = Connection::open(&db_path)?;
809
810    // Session-related key patterns
811    let session_patterns = [
812        "chat.",
813        "memento/interactive-session",
814        "agentSessions.",
815        "chatEditingSessions.",
816        "workbench.panel.chat",
817    ];
818
819    let query = if all {
820        "SELECT key, length(value) as size FROM ItemTable ORDER BY key".to_string()
821    } else {
822        // Build WHERE clause for session patterns
823        let clauses: Vec<String> = session_patterns
824            .iter()
825            .map(|p| format!("key LIKE '{}%'", p))
826            .collect();
827        format!(
828            "SELECT key, length(value) as size FROM ItemTable WHERE {} ORDER BY key",
829            clauses.join(" OR ")
830        )
831    };
832
833    let mut stmt = conn.prepare(&query)?;
834    let rows: Vec<(String, i64)> = stmt
835        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
836        .filter_map(|r| r.ok())
837        .collect();
838
839    if json {
840        let entries: Vec<serde_json::Value> = rows
841            .iter()
842            .map(|(key, size)| {
843                serde_json::json!({
844                    "key": key,
845                    "size": size,
846                })
847            })
848            .collect();
849        let output = serde_json::json!({
850            "workspace": ws_hash,
851            "dbPath": db_path.display().to_string(),
852            "keys": entries,
853        });
854        println!("{}", serde_json::to_string_pretty(&output)?);
855        return Ok(());
856    }
857
858    println!(
859        "\n  {} {} ({})\n",
860        "Workspace:".bold(),
861        ws_hash.cyan(),
862        db_path.display()
863    );
864
865    let qualifier = if all { "All" } else { "Session-related" };
866    println!(
867        "  {} ({} keys)\n",
868        format!("{} Keys in state.vscdb", qualifier)
869            .bold()
870            .underline(),
871        rows.len()
872    );
873
874    for (key, size) in &rows {
875        let size_str = if *size > 1024 * 1024 {
876            format!("{:.1} MB", *size as f64 / 1024.0 / 1024.0)
877        } else if *size > 1024 {
878            format!("{:.1} KB", *size as f64 / 1024.0)
879        } else {
880            format!("{} B", size)
881        };
882
883        println!(
884            "  {} {} ({})",
885            "•".bright_black(),
886            key.cyan(),
887            size_str.bright_black()
888        );
889    }
890
891    println!();
892    Ok(())
893}
894
895/// `chasm inspect files` — List all files in the chatSessions directory with details.
896pub fn inspect_files(path: Option<&str>, workspace_id: Option<&str>, json: bool) -> Result<()> {
897    let (ws_hash, _db_path) = resolve(path, workspace_id)?;
898    let storage_path = get_workspace_storage_path()?;
899    let chat_dir = storage_path.join(&ws_hash).join("chatSessions");
900
901    if !chat_dir.exists() {
902        if json {
903            println!(
904                "{}",
905                serde_json::to_string_pretty(&serde_json::json!({
906                    "workspace": ws_hash,
907                    "chatDir": chat_dir.display().to_string(),
908                    "exists": false,
909                    "files": [],
910                }))?
911            );
912        } else {
913            println!(
914                "\n  {} chatSessions directory does not exist: {}\n",
915                "✗".red(),
916                chat_dir.display()
917            );
918        }
919        return Ok(());
920    }
921
922    let mut files: Vec<(String, u64, String)> = Vec::new();
923    if let Ok(entries) = std::fs::read_dir(&chat_dir) {
924        for entry in entries.flatten() {
925            let p = entry.path();
926            if p.is_file() {
927                let name = p
928                    .file_name()
929                    .unwrap_or_default()
930                    .to_string_lossy()
931                    .to_string();
932                let size = p.metadata().map(|m| m.len()).unwrap_or(0);
933
934                // Detect format for session files
935                let format_str = if name.ends_with(".json") || name.ends_with(".jsonl") {
936                    match std::fs::read_to_string(&p) {
937                        Ok(content) => {
938                            let info = detect_session_format(&content);
939                            format!(
940                                "{:?} {} ({:.0}%)",
941                                info.format,
942                                info.schema_version,
943                                info.confidence * 100.0
944                            )
945                        }
946                        Err(_) => "unreadable".to_string(),
947                    }
948                } else {
949                    let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("unknown");
950                    ext.to_string()
951                };
952
953                files.push((name, size, format_str));
954            }
955        }
956    }
957
958    files.sort_by(|a, b| a.0.cmp(&b.0));
959
960    if json {
961        let entries: Vec<serde_json::Value> = files
962            .iter()
963            .map(|(name, size, format)| {
964                serde_json::json!({
965                    "name": name,
966                    "size": size,
967                    "format": format,
968                })
969            })
970            .collect();
971        let output = serde_json::json!({
972            "workspace": ws_hash,
973            "chatDir": chat_dir.display().to_string(),
974            "exists": true,
975            "fileCount": files.len(),
976            "files": entries,
977        });
978        println!("{}", serde_json::to_string_pretty(&output)?);
979        return Ok(());
980    }
981
982    println!(
983        "\n  {} {} ({})\n",
984        "Workspace:".bold(),
985        ws_hash.cyan(),
986        chat_dir.display()
987    );
988    println!(
989        "  {} ({} files)\n",
990        "chatSessions Directory".bold().underline(),
991        files.len()
992    );
993
994    let mut total_size: u64 = 0;
995    for (name, size, format) in &files {
996        total_size += size;
997        let size_str = if *size > 1024 * 1024 {
998            format!("{:.1} MB", *size as f64 / 1024.0 / 1024.0)
999        } else if *size > 1024 {
1000            format!("{:.1} KB", *size as f64 / 1024.0)
1001        } else {
1002            format!("{} B", size)
1003        };
1004
1005        let stub_marker = if *size < 500 && (name.ends_with(".json") || name.ends_with(".jsonl")) {
1006            " STUB".red().to_string()
1007        } else {
1008            String::new()
1009        };
1010
1011        println!(
1012            "  {} ({}, {}){}",
1013            name.cyan(),
1014            size_str.bright_black(),
1015            format.bright_black(),
1016            stub_marker,
1017        );
1018    }
1019
1020    let total_str = if total_size > 1024 * 1024 {
1021        format!("{:.1} MB", total_size as f64 / 1024.0 / 1024.0)
1022    } else if total_size > 1024 {
1023        format!("{:.1} KB", total_size as f64 / 1024.0)
1024    } else {
1025        format!("{} B", total_size)
1026    };
1027
1028    println!("\n  {} {}\n", "Total size:".bold(), total_str);
1029
1030    Ok(())
1031}
1032
1033// ── Rebuild ────────────────────────────────────────────────────────────────
1034
1035/// `chasm inspect rebuild` — Rebuild session index and model cache from files
1036/// on disk. Scans chatSessions/, parses each session file, builds the index
1037/// with full metadata (title, timestamps, request count), overwrites
1038/// `chat.ChatSessionStore.index` and `agentSessions.model.cache`, cleans up
1039/// `agentSessions.state.cache`, and fixes the active-session memento.
1040pub fn inspect_rebuild(
1041    path: Option<&str>,
1042    workspace_id: Option<&str>,
1043    dry_run: bool,
1044    json: bool,
1045) -> Result<()> {
1046    let (ws_hash, db_path) = resolve(path, workspace_id)?;
1047
1048    // Locate chatSessions directory
1049    let ws_storage = get_workspace_storage_path()?;
1050    let chat_dir = ws_storage.join(&ws_hash).join("chatSessions");
1051
1052    if !chat_dir.exists() {
1053        if json {
1054            println!("{{\"error\": \"no chatSessions directory\"}}");
1055        } else {
1056            println!(
1057                "\n  {} No chatSessions directory found for workspace {}",
1058                "ERROR:".red().bold(),
1059                ws_hash.cyan()
1060            );
1061        }
1062        return Ok(());
1063    }
1064
1065    // Read current state for comparison
1066    let old_index = read_chat_session_index(&db_path).unwrap_or_default();
1067    let old_model_cache = read_model_cache(&db_path).unwrap_or_default();
1068
1069    // Scan session files on disk (prefer .jsonl over .json for same session ID)
1070    let mut session_files: std::collections::HashMap<String, PathBuf> =
1071        std::collections::HashMap::new();
1072    for entry in std::fs::read_dir(&chat_dir)? {
1073        let entry = entry?;
1074        let p = entry.path();
1075        if !p.is_file() {
1076            continue;
1077        }
1078        let ext = p
1079            .extension()
1080            .map(|e| e.to_string_lossy().to_string())
1081            .unwrap_or_default();
1082        if !is_session_file_extension(std::ffi::OsStr::new(&ext)) {
1083            continue;
1084        }
1085        // Skip backup/recovery files
1086        let fname = p
1087            .file_name()
1088            .unwrap_or_default()
1089            .to_string_lossy()
1090            .to_string();
1091        if fname.contains(".bak") || fname.contains(".pre-restore") || fname.contains(".pre_bak") {
1092            continue;
1093        }
1094        if let Some(stem) = p.file_stem() {
1095            let stem_str = stem.to_string_lossy().to_string();
1096            let is_jsonl = ext == "jsonl";
1097            if !session_files.contains_key(&stem_str) || is_jsonl {
1098                session_files.insert(stem_str, p);
1099            }
1100        }
1101    }
1102
1103    // Parse each session file and build index entries
1104    let mut sessions: Vec<SessionInfo> = Vec::new();
1105    let mut skipped: Vec<(String, String)> = Vec::new(); // (filename, reason)
1106
1107    for (stem, fpath) in &session_files {
1108        let size = std::fs::metadata(fpath).map(|m| m.len()).unwrap_or(0);
1109
1110        match parse_session_file(fpath) {
1111            Ok(session) => {
1112                let session_id = session.session_id.clone().unwrap_or_else(|| stem.clone());
1113                let title = session.title();
1114                let is_empty = session.is_empty();
1115                let request_count = session.requests.len();
1116                let created = session.creation_date;
1117                let last_message_date = session.last_message_date;
1118                let fname = fpath
1119                    .file_name()
1120                    .unwrap_or_default()
1121                    .to_string_lossy()
1122                    .to_string();
1123
1124                sessions.push(SessionInfo {
1125                    session_id,
1126                    title,
1127                    request_count,
1128                    is_empty,
1129                    created,
1130                    last_message_date,
1131                    file: fname,
1132                    size,
1133                });
1134            }
1135            Err(e) => {
1136                let fname = fpath
1137                    .file_name()
1138                    .unwrap_or_default()
1139                    .to_string_lossy()
1140                    .to_string();
1141                skipped.push((fname, e.to_string()));
1142            }
1143        }
1144    }
1145
1146    // Sort by last_message_date descending (most recent first)
1147    sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
1148
1149    let non_empty: Vec<&SessionInfo> = sessions.iter().filter(|s| !s.is_empty).collect();
1150    let empty: Vec<&SessionInfo> = sessions.iter().filter(|s| s.is_empty).collect();
1151
1152    if json {
1153        // JSON output
1154        #[derive(serde::Serialize)]
1155        struct RebuildResult {
1156            workspace: String,
1157            dry_run: bool,
1158            sessions_total: usize,
1159            sessions_non_empty: usize,
1160            sessions_empty: usize,
1161            sessions_skipped: usize,
1162            old_index_count: usize,
1163            old_model_cache_count: usize,
1164            new_index_count: usize,
1165            new_model_cache_count: usize,
1166            sessions: Vec<SessionSummary>,
1167            skipped: Vec<SkippedFile>,
1168        }
1169        #[derive(serde::Serialize)]
1170        struct SessionSummary {
1171            session_id: String,
1172            title: String,
1173            requests: usize,
1174            is_empty: bool,
1175            created: i64,
1176            last_message_date: i64,
1177            file: String,
1178            size: u64,
1179        }
1180        #[derive(serde::Serialize)]
1181        struct SkippedFile {
1182            file: String,
1183            reason: String,
1184        }
1185
1186        let result = RebuildResult {
1187            workspace: ws_hash.clone(),
1188            dry_run,
1189            sessions_total: sessions.len(),
1190            sessions_non_empty: non_empty.len(),
1191            sessions_empty: empty.len(),
1192            sessions_skipped: skipped.len(),
1193            old_index_count: old_index.entries.len(),
1194            old_model_cache_count: old_model_cache.len(),
1195            new_index_count: sessions.len(),
1196            new_model_cache_count: non_empty.len(),
1197            sessions: sessions
1198                .iter()
1199                .map(|s| SessionSummary {
1200                    session_id: s.session_id.clone(),
1201                    title: s.title.clone(),
1202                    requests: s.request_count,
1203                    is_empty: s.is_empty,
1204                    created: s.created,
1205                    last_message_date: s.last_message_date,
1206                    file: s.file.clone(),
1207                    size: s.size,
1208                })
1209                .collect(),
1210            skipped: skipped
1211                .iter()
1212                .map(|(f, r)| SkippedFile {
1213                    file: f.clone(),
1214                    reason: r.clone(),
1215                })
1216                .collect(),
1217        };
1218
1219        println!("{}", serde_json::to_string_pretty(&result)?);
1220
1221        if !dry_run {
1222            // Build and write (same logic as below, just no text output)
1223            let (new_index, valid_ids) = build_index_from_sessions(&sessions);
1224            write_chat_session_index(&db_path, &new_index)?;
1225            rebuild_model_cache(&db_path, &new_index)?;
1226            cleanup_state_cache(&db_path, &valid_ids)?;
1227            let preferred = preferred_session_id(&new_index);
1228            let _ = fix_session_memento(&db_path, &valid_ids, preferred.as_deref());
1229        }
1230
1231        return Ok(());
1232    }
1233
1234    // Text output
1235    println!(
1236        "\n  {} {} ({})\n",
1237        "Workspace:".bold(),
1238        ws_hash.cyan(),
1239        db_path.display()
1240    );
1241
1242    // Current state summary
1243    println!("  {}", "Current State".bold().underline());
1244    println!(
1245        "  Index:       {} entries",
1246        old_index.entries.len().to_string().cyan()
1247    );
1248    println!(
1249        "  Model cache: {} entries",
1250        old_model_cache.len().to_string().cyan()
1251    );
1252    println!();
1253
1254    // Session scan results
1255    println!("  {}", "Scanned Sessions".bold().underline());
1256    for s in &sessions {
1257        let status = if s.is_empty {
1258            "\u{26A0}".yellow() // ⚠
1259        } else {
1260            "\u{2714}".green() // ✔
1261        };
1262        let title_display = if s.title.len() > 50 {
1263            format!("{}...", &s.title[..47])
1264        } else {
1265            s.title.clone()
1266        };
1267        let size_str = if s.size > 1024 * 1024 {
1268            format!("{:.1} MB", s.size as f64 / 1024.0 / 1024.0)
1269        } else if s.size > 1024 {
1270            format!("{:.1} KB", s.size as f64 / 1024.0)
1271        } else {
1272            format!("{} B", s.size)
1273        };
1274        println!(
1275            "  {} {} ({} req, {}) \"{}\"",
1276            status,
1277            &s.session_id[..12.min(s.session_id.len())].bright_black(),
1278            s.request_count.to_string().cyan(),
1279            size_str.bright_black(),
1280            title_display
1281        );
1282    }
1283
1284    if !skipped.is_empty() {
1285        println!();
1286        for (fname, reason) in &skipped {
1287            println!(
1288                "  {} {} — {}",
1289                "\u{2717}".red(), // ✗
1290                fname.bright_black(),
1291                reason.bright_black()
1292            );
1293        }
1294    }
1295
1296    println!();
1297    println!("  {}", "Rebuild Plan".bold().underline());
1298    println!(
1299        "  Index:       {} → {} entries",
1300        old_index.entries.len().to_string().bright_black(),
1301        sessions.len().to_string().green()
1302    );
1303    println!(
1304        "  Model cache: {} → {} entries (non-empty sessions)",
1305        old_model_cache.len().to_string().bright_black(),
1306        non_empty.len().to_string().green()
1307    );
1308
1309    if dry_run {
1310        println!(
1311            "\n  {} Dry run — no changes written.\n",
1312            "[DRY RUN]".yellow().bold()
1313        );
1314        return Ok(());
1315    }
1316
1317    // Build new index
1318    let (new_index, valid_ids) = build_index_from_sessions(&sessions);
1319
1320    // Write index
1321    write_chat_session_index(&db_path, &new_index)?;
1322
1323    // Rebuild model cache from the new index
1324    let model_count = rebuild_model_cache(&db_path, &new_index)?;
1325
1326    // Cleanup state cache
1327    let state_removed = cleanup_state_cache(&db_path, &valid_ids).unwrap_or(0);
1328
1329    // Fix memento
1330    let preferred = preferred_session_id(&new_index);
1331    let memento_fixed =
1332        fix_session_memento(&db_path, &valid_ids, preferred.as_deref()).unwrap_or(false);
1333
1334    println!();
1335    println!("  {}", "Results".bold().underline());
1336    println!(
1337        "  {} Index written: {} entries",
1338        "\u{2714}".green(),
1339        new_index.entries.len().to_string().cyan()
1340    );
1341    println!(
1342        "  {} Model cache rebuilt: {} entries",
1343        "\u{2714}".green(),
1344        model_count.to_string().cyan()
1345    );
1346    if state_removed > 0 {
1347        println!(
1348            "  {} State cache: removed {} stale entries",
1349            "\u{2714}".green(),
1350            state_removed.to_string().cyan()
1351        );
1352    }
1353    if memento_fixed {
1354        println!(
1355            "  {} Memento updated → {}",
1356            "\u{2714}".green(),
1357            preferred.as_deref().unwrap_or("(first valid)").cyan()
1358        );
1359    }
1360
1361    println!(
1362        "\n  {} If VS Code is open, {} it (Alt+F4) and reopen the project.\n",
1363        "NOTE:".yellow().bold(),
1364        "quit".bold()
1365    );
1366
1367    Ok(())
1368}
1369
1370/// Build a `ChatSessionIndex` from parsed session info.
1371fn build_index_from_sessions(
1372    sessions: &[SessionInfo],
1373) -> (crate::models::ChatSessionIndex, HashSet<String>) {
1374    let mut entries = HashMap::new();
1375    let mut valid_ids = HashSet::new();
1376
1377    for s in sessions {
1378        valid_ids.insert(s.session_id.clone());
1379        entries.insert(
1380            s.session_id.clone(),
1381            ChatSessionIndexEntry {
1382                session_id: s.session_id.clone(),
1383                title: s.title.clone(),
1384                last_message_date: s.last_message_date,
1385                timing: Some(ChatSessionTiming {
1386                    created: s.created,
1387                    last_request_started: Some(s.last_message_date),
1388                    last_request_ended: Some(s.last_message_date),
1389                }),
1390                last_response_state: 1,
1391                initial_location: "panel".to_string(),
1392                is_empty: s.is_empty,
1393                is_imported: Some(false),
1394                has_pending_edits: Some(false),
1395                is_external: Some(false),
1396            },
1397        );
1398    }
1399
1400    (
1401        crate::models::ChatSessionIndex {
1402            version: 1,
1403            entries,
1404        },
1405        valid_ids,
1406    )
1407}
1408
1409/// Find the most recently active non-empty session ID from an index.
1410fn preferred_session_id(index: &crate::models::ChatSessionIndex) -> Option<String> {
1411    index
1412        .entries
1413        .iter()
1414        .filter(|(_, e)| !e.is_empty)
1415        .max_by_key(|(_, e)| e.last_message_date)
1416        .map(|(id, _)| id.clone())
1417}
1418
1419// Private helper struct used by inspect_rebuild (not part of models.rs since
1420// it's purely internal to the rebuild flow).
1421#[derive(serde::Serialize)]
1422struct SessionInfo {
1423    session_id: String,
1424    title: String,
1425    request_count: usize,
1426    is_empty: bool,
1427    created: i64,
1428    last_message_date: i64,
1429    file: String,
1430    size: u64,
1431}