1use 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
22fn 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
42fn 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
51fn 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
64fn 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
73pub 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 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 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 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
198pub 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 let history = read_db_json(&db_path, "memento/interactive-session")?;
213 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 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 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 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
312pub 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 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 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 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
433pub 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 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 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 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 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 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 let file_path = match file_path {
691 Some(p) => p.to_path_buf(),
692 None => {
693 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 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 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 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 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 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 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 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
799pub 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 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 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
895pub 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 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
1033pub 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 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 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 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 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 let mut sessions: Vec<SessionInfo> = Vec::new();
1105 let mut skipped: Vec<(String, String)> = Vec::new(); 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 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 #[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 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 println!(
1236 "\n {} {} ({})\n",
1237 "Workspace:".bold(),
1238 ws_hash.cyan(),
1239 db_path.display()
1240 );
1241
1242 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 println!(" {}", "Scanned Sessions".bold().underline());
1256 for s in &sessions {
1257 let status = if s.is_empty {
1258 "\u{26A0}".yellow() } else {
1260 "\u{2714}".green() };
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(), 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 let (new_index, valid_ids) = build_index_from_sessions(&sessions);
1319
1320 write_chat_session_index(&db_path, &new_index)?;
1322
1323 let model_count = rebuild_model_cache(&db_path, &new_index)?;
1325
1326 let state_removed = cleanup_state_cache(&db_path, &valid_ids).unwrap_or(0);
1328
1329 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
1370fn 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
1409fn 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#[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}