1use anyhow::Result;
10use colored::*;
11use std::collections::HashSet;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14
15use crate::error::CsmError;
16use crate::models::ChatSession;
17use crate::storage::{
18 add_session_to_index, cleanup_state_cache, close_vscode_and_wait, diagnose_workspace_sessions,
19 fix_session_memento, get_workspace_storage_db, is_session_file_extension, is_vscode_running,
20 parse_session_file, parse_session_json, read_chat_session_index, rebuild_model_cache,
21 recover_from_json_bak, register_all_sessions_from_directory, reopen_vscode,
22 repair_workspace_sessions, trim_session_jsonl,
23};
24use crate::workspace::{
25 discover_workspaces, find_workspace_by_path, normalize_path,
26 recover_orphaned_sessions_from_old_hashes,
27};
28
29fn confirm_close_vscode(force: bool) -> bool {
32 if force {
33 return true;
34 }
35 print!(
36 "{} VS Code will be closed. Continue? [y/N] ",
37 "[?]".yellow()
38 );
39 std::io::stdout().flush().ok();
40 let mut input = String::new();
41 if std::io::stdin().read_line(&mut input).is_err() {
42 return false;
43 }
44 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
45}
46
47pub fn resolve_path(path: Option<&str>) -> PathBuf {
49 match path {
50 Some(p) => {
51 let path = PathBuf::from(p);
52 path.canonicalize().unwrap_or(path)
53 }
54 None => std::env::current_dir().unwrap_or_default(),
55 }
56}
57
58pub fn register_all(
60 project_path: Option<&str>,
61 merge: bool,
62 force: bool,
63 close_vscode: bool,
64 reopen: bool,
65 write_only: bool,
66) -> Result<()> {
67 let path = resolve_path(project_path);
68 let should_close = close_vscode || reopen;
70
71 if merge {
72 println!(
73 "{} Merging and registering all sessions for: {}",
74 "[CSM]".cyan().bold(),
75 path.display()
76 );
77
78 let path_str = path.to_string_lossy().to_string();
80 return crate::commands::history_merge(
81 Some(&path_str),
82 None, force, false, );
86 }
87
88 println!(
89 "{} Registering all sessions for: {}",
90 "[CSM]".cyan().bold(),
91 path.display()
92 );
93
94 let path_str = path.to_string_lossy().to_string();
96 let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
97 .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
98
99 let chat_sessions_dir = ws_path.join("chatSessions");
100
101 if !chat_sessions_dir.exists() {
102 println!(
103 "{} No chatSessions directory found at: {}",
104 "[!]".yellow(),
105 chat_sessions_dir.display()
106 );
107 return Ok(());
108 }
109
110 let in_vscode_terminal = std::env::var("TERM_PROGRAM")
112 .map(|v| v.to_lowercase().contains("vscode"))
113 .unwrap_or(false);
114
115 let vscode_was_running = is_vscode_running();
117 let mut will_reopen = reopen;
118 if vscode_was_running {
119 if write_only {
120 println!(
124 " {} VS Code is running. Writing to index with shutdown watchdog.",
125 "[*]".yellow()
126 );
127 } else if should_close {
128 if in_vscode_terminal && !force {
130 println!(
131 "{} Cannot close VS Code from its integrated terminal.",
132 "[!]".yellow()
133 );
134 println!(
135 " Use {} to write to the index now (a background watchdog will",
136 "--write-only".cyan()
137 );
138 println!(" re-apply after VS Code exits to survive shutdown).");
139 println!(
140 " Or run this command from an {} with {}.",
141 "external terminal".cyan(),
142 "--reopen".cyan()
143 );
144 return Err(CsmError::VSCodeRunning.into());
145 }
146 if !confirm_close_vscode(force) {
147 println!("{} Aborted.", "[!]".yellow());
148 return Ok(());
149 }
150 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
151 close_vscode_and_wait(30)?;
152 println!(" {} VS Code closed.", "[OK]".green());
153 } else if force {
154 if in_vscode_terminal {
156 println!(
158 " {} VS Code is running (in its terminal). Writing with shutdown watchdog.",
159 "[*]".yellow()
160 );
161 } else {
162 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
164 close_vscode_and_wait(30)?;
165 println!(" {} VS Code closed.", "[OK]".green());
166 will_reopen = true;
167 }
168 } else {
169 println!(
171 "{} VS Code is running. Its in-memory cache will overwrite index changes on shutdown.",
172 "[!]".yellow()
173 );
174 if in_vscode_terminal {
175 println!(
176 " Use {} to write now (a background watchdog ensures persistence).",
177 "--write-only".cyan()
178 );
179 } else {
180 println!(
181 " Use {} to close VS Code first, register, and reopen.",
182 "--reopen".cyan()
183 );
184 println!(
185 " Use {} to skip the confirmation prompt.",
186 "--force".cyan()
187 );
188 }
189 return Err(CsmError::VSCodeRunning.into());
190 }
191 }
192
193 let sessions_on_disk = count_sessions_in_directory(&chat_sessions_dir)?;
195 println!(
196 " Found {} session files on disk",
197 sessions_on_disk.to_string().green()
198 );
199
200 let registered = register_all_sessions_from_directory(&ws_id, &chat_sessions_dir, true)?;
202
203 println!(
204 "\n{} Registered {} sessions in VS Code's index",
205 "[OK]".green().bold(),
206 registered.to_string().cyan()
207 );
208
209 let db_path = get_workspace_storage_db(&ws_id)?;
211 match read_chat_session_index(&db_path) {
212 Ok(index) => match rebuild_model_cache(&db_path, &index) {
213 Ok(n) => {
214 println!(
215 "{} Rebuilt model cache with {} entries",
216 "[OK]".green().bold(),
217 n.to_string().cyan()
218 );
219 }
220 Err(e) => {
221 println!(
222 "{} Failed to rebuild model cache: {}",
223 "[WARN]".yellow(),
224 e
225 );
226 }
227 },
228 Err(e) => {
229 println!(
230 "{} Failed to read index for model cache rebuild: {}",
231 "[WARN]".yellow(),
232 e
233 );
234 }
235 }
236
237 {
239 let mut valid_ids: HashSet<String> = HashSet::new();
240 if chat_sessions_dir.exists() {
241 for entry in std::fs::read_dir(&chat_sessions_dir)? {
242 let entry = entry?;
243 let p = entry.path();
244 if p.extension().is_some_and(|e| e == "jsonl") {
245 if let Some(stem) = p.file_stem() {
246 valid_ids.insert(stem.to_string_lossy().to_string());
247 }
248 }
249 }
250 }
251 if let Ok(n) = cleanup_state_cache(&db_path, &valid_ids) {
252 if n > 0 {
253 println!(
254 "{} Cleaned {} stale state cache entries",
255 "[OK]".green().bold(),
256 n.to_string().cyan()
257 );
258 }
259 }
260 }
261
262 let wrote_while_running = vscode_was_running && is_vscode_running();
265 if wrote_while_running {
266 match spawn_registration_watchdog(&ws_id, &chat_sessions_dir, Some(&path_str)) {
267 Ok(()) => {
268 println!(
269 "\n{} Background watchdog spawned to re-apply index after VS Code exits.",
270 "[OK]".green()
271 );
272 println!(
273 " Sessions will {} across VS Code restarts.",
274 "persist".green().bold()
275 );
276 println!(
277 " To see them now, press {} and run {}",
278 "Ctrl+Shift+P".cyan(),
279 "Developer: Reload Window".cyan()
280 );
281 }
282 Err(e) => {
283 println!("\n{} Could not spawn watchdog: {}", "[!]".yellow(), e);
284 println!(
285 " {} VS Code's in-memory cache may overwrite these changes on shutdown.",
286 "[!]".red()
287 );
288 println!(
289 " Use {} and run {} to pick up the changes NOW",
290 "Ctrl+Shift+P".cyan(),
291 "Developer: Reload Window".cyan()
292 );
293 println!(
294 " {} Do NOT restart VS Code — that will lose the registered sessions.",
295 "[!]".red().bold()
296 );
297 }
298 }
299 }
300
301 if will_reopen && !is_vscode_running() {
303 println!(" {} Reopening VS Code...", "[*]".yellow());
304 reopen_vscode(Some(&path_str))?;
305 println!(
306 " {} VS Code launched. Sessions should appear in Copilot Chat history.",
307 "[OK]".green()
308 );
309 } else if should_close && vscode_was_running && !is_vscode_running() && !will_reopen {
310 println!(
311 "\n{} VS Code was closed. Reopen it to see the recovered sessions.",
312 "[!]".yellow()
313 );
314 println!(" Run: {}", format!("code {}", path.display()).cyan());
315 }
316
317 Ok(())
318}
319
320fn spawn_registration_watchdog(
324 ws_id: &str,
325 chat_sessions_dir: &Path,
326 _project_path: Option<&str>,
327) -> Result<()> {
328 let pending_file = std::env::temp_dir().join(format!("chasm_pending_{}.json", ws_id));
330 let pending = serde_json::json!({
331 "workspace_id": ws_id,
332 "chat_sessions_dir": chat_sessions_dir.to_string_lossy(),
333 });
334 std::fs::write(&pending_file, serde_json::to_string_pretty(&pending)?)?;
335
336 let exe = std::env::current_exe()?;
338
339 #[cfg(windows)]
340 {
341 use std::os::windows::process::CommandExt;
342 const CREATE_NO_WINDOW: u32 = 0x08000000;
343 const DETACHED_PROCESS: u32 = 0x00000008;
344
345 std::process::Command::new(&exe)
346 .args(["internal", "apply-pending", &pending_file.to_string_lossy()])
347 .stdout(std::process::Stdio::null())
348 .stderr(std::process::Stdio::null())
349 .stdin(std::process::Stdio::null())
350 .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
351 .spawn()?;
352 }
353
354 #[cfg(not(windows))]
355 {
356 std::process::Command::new(&exe)
357 .args(["internal", "apply-pending", &pending_file.to_string_lossy()])
358 .stdout(std::process::Stdio::null())
359 .stderr(std::process::Stdio::null())
360 .stdin(std::process::Stdio::null())
361 .spawn()?;
362 }
363
364 Ok(())
365}
366
367pub fn apply_pending_index(pending_file: &str) -> Result<()> {
370 let content = std::fs::read_to_string(pending_file)?;
371 let pending: serde_json::Value = serde_json::from_str(&content)?;
372
373 let ws_id = pending["workspace_id"]
374 .as_str()
375 .ok_or_else(|| CsmError::InvalidSessionFormat("missing workspace_id".into()))?;
376 let chat_sessions_dir = PathBuf::from(
377 pending["chat_sessions_dir"]
378 .as_str()
379 .ok_or_else(|| CsmError::InvalidSessionFormat("missing chat_sessions_dir".into()))?,
380 );
381
382 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(600);
384 while is_vscode_running() {
385 if std::time::Instant::now() >= deadline {
386 let _ = std::fs::remove_file(pending_file);
388 return Ok(());
389 }
390 std::thread::sleep(std::time::Duration::from_secs(2));
391 }
392
393 std::thread::sleep(std::time::Duration::from_secs(2));
395
396 if chat_sessions_dir.exists() {
398 let _ = register_all_sessions_from_directory(ws_id, &chat_sessions_dir, true);
399 }
400
401 let _ = std::fs::remove_file(pending_file);
403
404 Ok(())
405}
406
407pub fn register_sessions(
409 ids: &[String],
410 titles: Option<&[String]>,
411 project_path: Option<&str>,
412 force: bool,
413) -> Result<()> {
414 let path = resolve_path(project_path);
415
416 let path_str = path.to_string_lossy().to_string();
418 let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
419 .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
420
421 let chat_sessions_dir = ws_path.join("chatSessions");
422
423 if !force && is_vscode_running() {
425 println!(
426 "{} VS Code is running. Use {} to register anyway.",
427 "[!]".yellow(),
428 "--force".cyan()
429 );
430 return Err(CsmError::VSCodeRunning.into());
431 }
432
433 let db_path = get_workspace_storage_db(&ws_id)?;
435
436 let mut registered_count = 0;
437
438 if let Some(titles) = titles {
439 println!(
441 "{} Registering {} sessions by title:",
442 "[CSM]".cyan().bold(),
443 titles.len()
444 );
445
446 let sessions = find_sessions_by_titles(&chat_sessions_dir, titles)?;
447
448 for (session, session_path) in sessions {
449 let session_id = session.session_id.clone().unwrap_or_else(|| {
450 session_path
451 .file_stem()
452 .map(|s| s.to_string_lossy().to_string())
453 .unwrap_or_default()
454 });
455 let title = session.title();
456
457 add_session_to_index(
458 &db_path,
459 &session_id,
460 &title,
461 session.last_message_date,
462 session.is_imported,
463 &session.initial_location,
464 session.is_empty(),
465 )?;
466
467 let id_display = if session_id.len() > 12 {
468 &session_id[..12]
469 } else {
470 &session_id
471 };
472 println!(
473 " {} {} (\"{}\")",
474 "[OK]".green(),
475 id_display.cyan(),
476 title.yellow()
477 );
478 registered_count += 1;
479 }
480 } else {
481 println!(
483 "{} Registering {} sessions by ID:",
484 "[CSM]".cyan().bold(),
485 ids.len()
486 );
487
488 for session_id in ids {
489 match find_session_file(&chat_sessions_dir, session_id) {
490 Ok(session_file) => {
491 let session = parse_session_file(&session_file)?;
492
493 let title = session.title();
494 let actual_session_id = session
495 .session_id
496 .clone()
497 .unwrap_or_else(|| session_id.to_string());
498
499 add_session_to_index(
500 &db_path,
501 &actual_session_id,
502 &title,
503 session.last_message_date,
504 session.is_imported,
505 &session.initial_location,
506 session.is_empty(),
507 )?;
508
509 let id_display = if actual_session_id.len() > 12 {
510 &actual_session_id[..12]
511 } else {
512 &actual_session_id
513 };
514 println!(
515 " {} {} (\"{}\")",
516 "[OK]".green(),
517 id_display.cyan(),
518 title.yellow()
519 );
520 registered_count += 1;
521 }
522 Err(e) => {
523 println!(
524 " {} {} - {}",
525 "[ERR]".red(),
526 session_id.cyan(),
527 e.to_string().red()
528 );
529 }
530 }
531 }
532 }
533
534 println!(
535 "\n{} Registered {} sessions in VS Code's index",
536 "[OK]".green().bold(),
537 registered_count.to_string().cyan()
538 );
539
540 if force && is_vscode_running() {
541 println!(
542 " {} Sessions should appear in VS Code immediately",
543 "->".cyan()
544 );
545 }
546
547 Ok(())
548}
549
550pub fn list_orphaned(project_path: Option<&str>) -> Result<()> {
552 let path = resolve_path(project_path);
553
554 println!(
555 "{} Finding orphaned sessions for: {}",
556 "[CSM]".cyan().bold(),
557 path.display()
558 );
559
560 let path_str = path.to_string_lossy().to_string();
562 let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
563 .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
564
565 let chat_sessions_dir = ws_path.join("chatSessions");
566
567 if !chat_sessions_dir.exists() {
568 println!("{} No chatSessions directory found", "[!]".yellow());
569 return Ok(());
570 }
571
572 let db_path = get_workspace_storage_db(&ws_id)?;
574 let index = read_chat_session_index(&db_path)?;
575 let indexed_ids: HashSet<String> = index.entries.keys().cloned().collect();
576
577 println!(
578 " {} sessions currently in VS Code's index",
579 indexed_ids.len().to_string().cyan()
580 );
581
582 let mut orphaned_sessions = Vec::new();
584
585 let mut session_files: std::collections::HashMap<String, PathBuf> =
587 std::collections::HashMap::new();
588 for entry in std::fs::read_dir(&chat_sessions_dir)? {
589 let entry = entry?;
590 let path = entry.path();
591 if path
592 .extension()
593 .map(is_session_file_extension)
594 .unwrap_or(false)
595 {
596 if let Some(stem) = path.file_stem() {
597 let stem_str = stem.to_string_lossy().to_string();
598 let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
599 if !session_files.contains_key(&stem_str) || is_jsonl {
600 session_files.insert(stem_str, path);
601 }
602 }
603 }
604 }
605
606 for (_, path) in &session_files {
607 if let Ok(session) = parse_session_file(path) {
608 let session_id = session.session_id.clone().unwrap_or_else(|| {
609 path.file_stem()
610 .map(|s| s.to_string_lossy().to_string())
611 .unwrap_or_default()
612 });
613
614 if !indexed_ids.contains(&session_id) {
615 let title = session.title();
616 let msg_count = session.requests.len();
617 orphaned_sessions.push((session_id, title, msg_count, path.clone()));
618 }
619 }
620 }
621
622 if orphaned_sessions.is_empty() {
623 println!(
624 "\n{} No orphaned sessions found - all sessions are registered!",
625 "[OK]".green().bold()
626 );
627 return Ok(());
628 }
629
630 println!(
631 "\n{} Found {} orphaned sessions (on disk but not in index):\n",
632 "[!]".yellow().bold(),
633 orphaned_sessions.len().to_string().red()
634 );
635
636 for (session_id, title, msg_count, _path) in &orphaned_sessions {
637 let id_display = if session_id.len() > 12 {
638 &session_id[..12]
639 } else {
640 session_id
641 };
642 println!(
643 " {} {} ({} messages)",
644 id_display.cyan(),
645 format!("\"{}\"", title).yellow(),
646 msg_count
647 );
648 }
649
650 println!("\n{} To register all orphaned sessions:", "->".cyan());
651 println!(" csm register all --force");
652 println!("\n{} To register specific sessions:", "->".cyan());
653 println!(" csm register session <ID1> <ID2> ... --force");
654
655 Ok(())
656}
657
658fn count_sessions_in_directory(dir: &PathBuf) -> Result<usize> {
660 let mut session_ids: HashSet<String> = HashSet::new();
661 for entry in std::fs::read_dir(dir)? {
662 let entry = entry?;
663 let path = entry.path();
664 if path
665 .extension()
666 .map(is_session_file_extension)
667 .unwrap_or(false)
668 {
669 if let Some(stem) = path.file_stem() {
670 session_ids.insert(stem.to_string_lossy().to_string());
671 }
672 }
673 }
674 Ok(session_ids.len())
675}
676
677fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
679 let exact_jsonl = chat_sessions_dir.join(format!("{}.jsonl", session_id));
681 if exact_jsonl.exists() {
682 return Ok(exact_jsonl);
683 }
684 let exact_json = chat_sessions_dir.join(format!("{}.json", session_id));
685 if exact_json.exists() {
686 return Ok(exact_json);
687 }
688
689 let mut best_match: Option<PathBuf> = None;
691 for entry in std::fs::read_dir(chat_sessions_dir)? {
692 let entry = entry?;
693 let path = entry.path();
694
695 if path
696 .extension()
697 .map(is_session_file_extension)
698 .unwrap_or(false)
699 {
700 let filename = path
701 .file_stem()
702 .map(|s| s.to_string_lossy().to_string())
703 .unwrap_or_default();
704
705 if filename.starts_with(session_id) {
706 let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
707 if best_match.is_none() || is_jsonl {
708 best_match = Some(path.clone());
709 if is_jsonl {
710 return Ok(path);
711 }
712 }
713 continue;
714 }
715
716 if let Ok(session) = parse_session_file(&path) {
718 if let Some(ref sid) = session.session_id {
719 if sid.starts_with(session_id) || sid == session_id {
720 let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
721 if best_match.is_none() || is_jsonl {
722 best_match = Some(path.clone());
723 }
724 }
725 }
726 }
727 }
728 }
729
730 best_match.ok_or_else(|| CsmError::SessionNotFound(session_id.to_string()).into())
731}
732
733fn find_sessions_by_titles(
735 chat_sessions_dir: &PathBuf,
736 titles: &[String],
737) -> Result<Vec<(ChatSession, PathBuf)>> {
738 let mut matches = Vec::new();
739 let title_patterns: Vec<String> = titles.iter().map(|t| t.to_lowercase()).collect();
740
741 let mut session_files: std::collections::HashMap<String, PathBuf> =
743 std::collections::HashMap::new();
744 for entry in std::fs::read_dir(chat_sessions_dir)? {
745 let entry = entry?;
746 let path = entry.path();
747 if path
748 .extension()
749 .map(is_session_file_extension)
750 .unwrap_or(false)
751 {
752 if let Some(stem) = path.file_stem() {
753 let stem_str = stem.to_string_lossy().to_string();
754 let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
755 if !session_files.contains_key(&stem_str) || is_jsonl {
756 session_files.insert(stem_str, path);
757 }
758 }
759 }
760 }
761
762 for (_, path) in &session_files {
763 if let Ok(session) = parse_session_file(path) {
764 let session_title = session.title().to_lowercase();
765
766 for pattern in &title_patterns {
767 if session_title.contains(pattern) {
768 matches.push((session, path.clone()));
769 break;
770 }
771 }
772 }
773 }
774
775 if matches.is_empty() {
776 println!(
777 "{} No sessions found matching the specified titles",
778 "[!]".yellow()
779 );
780 }
781
782 Ok(matches)
783}
784
785pub fn register_recursive(
792 root_path: Option<&str>,
793 max_depth: Option<usize>,
794 force: bool,
795 dry_run: bool,
796 exclude_patterns: &[String],
797) -> Result<()> {
798 let root = resolve_path(root_path);
799 let root_normalized = normalize_path(&root.to_string_lossy());
800
801 println!(
802 "{} Scanning for workspaces under: {}",
803 "[CSM]".cyan().bold(),
804 root.display()
805 );
806
807 if dry_run {
808 println!("{} Dry run mode - no changes will be made", "[!]".yellow());
809 }
810
811 if !force && !dry_run && is_vscode_running() {
813 println!(
814 "{} VS Code is running. Use {} to register anyway.",
815 "[!]".yellow(),
816 "--force".cyan()
817 );
818 println!(" Note: VS Code uses WAL mode so this is generally safe.");
819 return Err(CsmError::VSCodeRunning.into());
820 }
821
822 let workspaces = discover_workspaces()?;
824
825 let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
827 .iter()
828 .filter_map(|p| glob::Pattern::new(p).ok())
829 .collect();
830
831 let default_excludes = [
833 "node_modules",
834 ".git",
835 "target",
836 "build",
837 "dist",
838 ".venv",
839 "venv",
840 "__pycache__",
841 ".cache",
842 "vendor",
843 ".cargo",
844 ];
845
846 let matching_workspaces: Vec<&crate::models::Workspace> = workspaces
850 .iter()
851 .filter(|ws| {
852 let Some(ref project_path) = ws.project_path else {
853 return false;
854 };
855
856 let ws_normalized = normalize_path(project_path);
858 if !ws_normalized.starts_with(&root_normalized) {
859 return false;
860 }
861
862 if let Some(max) = max_depth {
864 let suffix = &ws_normalized[root_normalized.len()..];
865 let suffix = suffix.trim_start_matches(['/', '\\']);
866 let depth = if suffix.is_empty() {
867 0
868 } else {
869 suffix.matches(['/', '\\']).count() + 1
870 };
871 if depth > max {
872 return false;
873 }
874 }
875
876 let relative = &ws_normalized[root_normalized.len()..];
878 let relative = relative.trim_start_matches(['/', '\\']);
879 let dir_name = project_path
880 .rsplit(['/', '\\'])
881 .next()
882 .unwrap_or("")
883 .to_lowercase();
884
885 for component in relative.split(['/', '\\']) {
887 if default_excludes.contains(&component) {
888 return false;
889 }
890 }
891
892 for pattern in &exclude_matchers {
894 if pattern.matches(relative) || pattern.matches(&dir_name) {
895 return false;
896 }
897 }
898
899 ws.has_chat_sessions
901 })
902 .collect();
903
904 let total_workspaces = matching_workspaces.len();
905 println!(
906 " Found {} workspaces with chat sessions under this path (from {} total)",
907 total_workspaces.to_string().cyan(),
908 workspaces.len().to_string().white()
909 );
910
911 let mut workspaces_processed = 0;
912 let mut total_sessions_registered = 0;
913 let mut workspaces_with_orphans: Vec<(String, usize, usize)> = Vec::new();
914
915 for (i, ws) in matching_workspaces.iter().enumerate() {
916 let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
917 let chat_sessions_dir = &ws.chat_sessions_path;
918
919 if (i + 1) % 25 == 0 || i + 1 == total_workspaces {
921 println!(
922 " ... processing {}/{}",
923 (i + 1).to_string().cyan(),
924 total_workspaces.to_string().white()
925 );
926 }
927
928 match count_orphaned_sessions(&ws.hash, chat_sessions_dir) {
930 Ok((on_disk, in_index, orphaned_count)) => {
931 workspaces_processed += 1;
932
933 if orphaned_count > 0 {
934 if dry_run {
935 println!(
936 " {} {} - {} sessions on disk, {} in index, {} orphaned",
937 "[DRY]".yellow(),
938 display_path.cyan(),
939 on_disk.to_string().white(),
940 in_index.to_string().white(),
941 orphaned_count.to_string().yellow()
942 );
943 workspaces_with_orphans.push((
944 display_path.to_string(),
945 orphaned_count,
946 orphaned_count,
947 ));
948 } else {
949 match register_all_sessions_from_directory(
951 &ws.hash,
952 chat_sessions_dir,
953 force,
954 ) {
955 Ok(registered) => {
956 total_sessions_registered += registered;
957 println!(
958 " {} {} - registered {} sessions",
959 "[+]".green(),
960 display_path.cyan(),
961 registered.to_string().green()
962 );
963 workspaces_with_orphans.push((
964 display_path.to_string(),
965 orphaned_count,
966 registered,
967 ));
968 }
969 Err(e) => {
970 println!(
971 " {} {} - error: {}",
972 "[!]".red(),
973 display_path.cyan(),
974 e
975 );
976 }
977 }
978 }
979 }
980 }
981 Err(e) => {
982 println!(
983 " {} {} - error checking: {}",
984 "[!]".yellow(),
985 display_path,
986 e
987 );
988 }
989 }
990 }
991
992 println!("\n{}", "═".repeat(60).cyan());
994 println!("{} Recursive scan complete", "[OK]".green().bold());
995 println!("{}", "═".repeat(60).cyan());
996 println!(
997 " Workspaces checked: {}",
998 workspaces_processed.to_string().cyan()
999 );
1000 println!(
1001 " Sessions registered: {}",
1002 total_sessions_registered.to_string().green()
1003 );
1004
1005 if !workspaces_with_orphans.is_empty() {
1006 println!("\n {} Workspaces with orphaned sessions:", "[+]".green());
1007 for (path, orphaned, registered) in &workspaces_with_orphans {
1008 let reg_str = if dry_run {
1009 format!("would register {}", registered)
1010 } else {
1011 format!("registered {}", registered)
1012 };
1013 println!(
1014 " {} ({} orphaned, {})",
1015 path.cyan(),
1016 orphaned.to_string().yellow(),
1017 reg_str.green()
1018 );
1019 }
1020 }
1021
1022 if total_sessions_registered > 0 && !dry_run {
1023 println!(
1024 "\n{} VS Code caches the session index in memory.",
1025 "[!]".yellow()
1026 );
1027 println!(" To see the new sessions, do one of the following:");
1028 println!(
1029 " * Run: {} (if CSM extension is installed)",
1030 "code --command csm.reloadAndShowChats".cyan()
1031 );
1032 println!(
1033 " * Or press {} in VS Code and run {}",
1034 "Ctrl+Shift+P".cyan(),
1035 "Developer: Reload Window".cyan()
1036 );
1037 println!(" * Or restart VS Code");
1038 }
1039
1040 Ok(())
1041}
1042
1043fn count_orphaned_sessions(
1045 workspace_id: &str,
1046 chat_sessions_dir: &Path,
1047) -> Result<(usize, usize, usize)> {
1048 let db_path = get_workspace_storage_db(workspace_id)?;
1050 let indexed_sessions = read_chat_session_index(&db_path)?;
1051 let indexed_ids: HashSet<String> = indexed_sessions.entries.keys().cloned().collect();
1052
1053 let mut disk_sessions: HashSet<String> = HashSet::new();
1055
1056 for entry in std::fs::read_dir(chat_sessions_dir)? {
1057 let entry = entry?;
1058 let path = entry.path();
1059
1060 if path
1061 .extension()
1062 .map(is_session_file_extension)
1063 .unwrap_or(false)
1064 {
1065 if let Some(stem) = path.file_stem() {
1066 disk_sessions.insert(stem.to_string_lossy().to_string());
1067 }
1068 }
1069 }
1070
1071 let on_disk = disk_sessions.len();
1072 let orphaned = disk_sessions
1073 .iter()
1074 .filter(|id| !indexed_ids.contains(*id))
1075 .count();
1076
1077 Ok((on_disk, indexed_ids.len(), orphaned))
1078}
1079
1080pub fn register_repair(
1082 project_path: Option<&str>,
1083 all: bool,
1084 recursive: bool,
1085 max_depth: Option<usize>,
1086 exclude_patterns: &[String],
1087 dry_run: bool,
1088 force: bool,
1089 close_vscode: bool,
1090 reopen: bool,
1091) -> Result<()> {
1092 if all {
1093 return register_repair_all(force, close_vscode, reopen);
1094 }
1095
1096 if recursive {
1097 return register_repair_recursive(
1098 project_path,
1099 max_depth,
1100 exclude_patterns,
1101 dry_run,
1102 force,
1103 close_vscode,
1104 reopen,
1105 );
1106 }
1107
1108 let path = resolve_path(project_path);
1109 let should_close = close_vscode || reopen;
1110
1111 println!(
1112 "{} Repairing sessions for: {}",
1113 "[CSM]".cyan().bold(),
1114 path.display()
1115 );
1116
1117 let path_str = path.to_string_lossy().to_string();
1119 let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
1120 .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
1121
1122 let chat_sessions_dir = ws_path.join("chatSessions");
1123
1124 if !chat_sessions_dir.exists() {
1125 println!(
1126 "{} No chatSessions directory found at: {}",
1127 "[!]".yellow(),
1128 chat_sessions_dir.display()
1129 );
1130 return Ok(());
1131 }
1132
1133 let vscode_was_running = is_vscode_running();
1135 if vscode_was_running {
1136 if should_close {
1137 if !confirm_close_vscode(force) {
1138 println!("{} Aborted.", "[!]".yellow());
1139 return Ok(());
1140 }
1141 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
1142 close_vscode_and_wait(30)?;
1143 println!(" {} VS Code closed.", "[OK]".green());
1144 } else if !force {
1145 println!(
1146 "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1147 "[!]".yellow()
1148 );
1149 println!(
1150 " Use {} to close VS Code first, or {} to force.",
1151 "--reopen".cyan(),
1152 "--force".cyan()
1153 );
1154 return Err(CsmError::VSCodeRunning.into());
1155 }
1156 }
1157
1158 println!(
1160 " {} Pass 0: Recovering orphaned sessions from old workspace hashes...",
1161 "[*]".cyan()
1162 );
1163 match recover_orphaned_sessions_from_old_hashes(&path_str) {
1164 Ok(0) => {}
1165 Ok(n) => {
1166 println!(
1167 " {} Recovered {} orphaned session(s) from old workspace hashes",
1168 "[OK]".green(),
1169 n.to_string().cyan()
1170 );
1171 }
1172 Err(e) => {
1173 println!(
1174 " {} Failed to recover orphaned sessions: {}",
1175 "[WARN]".yellow(),
1176 e
1177 );
1178 }
1179 }
1180
1181 println!(
1184 " {} Pass 0.5: Recovering sessions from .json.bak files...",
1185 "[*]".cyan()
1186 );
1187 match recover_from_json_bak(&chat_sessions_dir) {
1188 Ok(0) => {}
1189 Ok(n) => {
1190 println!(
1191 " {} Recovered {} session(s) from .json.bak backups",
1192 "[OK]".green(),
1193 n.to_string().cyan()
1194 );
1195 }
1196 Err(e) => {
1197 println!(
1198 " {} Failed to recover from .json.bak: {}",
1199 "[WARN]".yellow(),
1200 e
1201 );
1202 }
1203 }
1204
1205 println!(
1206 " {} Pass 1: Compacting JSONL files & fixing compat fields...",
1207 "[*]".cyan()
1208 );
1209 println!(
1210 " {} Pass 1.5: Converting skeleton .json files...",
1211 "[*]".cyan()
1212 );
1213 println!(" {} Pass 2: Fixing cancelled modelState...", "[*]".cyan());
1214 let (compacted, index_fixed) = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1215
1216 println!(" {} Pass 3: Index rebuilt.", "[*]".cyan());
1217 println!(
1218 "\n{} Repair complete: {} files compacted, {} index entries synced",
1219 "[OK]".green().bold(),
1220 compacted.to_string().cyan(),
1221 index_fixed.to_string().cyan()
1222 );
1223
1224 let mut deleted_json = 0;
1226 if chat_sessions_dir.exists() {
1227 let mut jsonl_sessions: HashSet<String> = HashSet::new();
1228 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1229 let entry = entry?;
1230 let p = entry.path();
1231 if p.extension().is_some_and(|e| e == "jsonl") {
1232 if let Some(stem) = p.file_stem() {
1233 jsonl_sessions.insert(stem.to_string_lossy().to_string());
1234 }
1235 }
1236 }
1237 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1238 let entry = entry?;
1239 let p = entry.path();
1240 if p.extension().is_some_and(|e| e == "json") {
1241 if let Some(stem) = p.file_stem() {
1242 if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1243 let bak = p.with_extension("json.bak");
1245 std::fs::rename(&p, &bak)?;
1246 println!(
1247 " {} Backed up stale .json: {} → {}",
1248 "[*]".yellow(),
1249 p.file_name().unwrap_or_default().to_string_lossy(),
1250 bak.file_name().unwrap_or_default().to_string_lossy()
1251 );
1252 deleted_json += 1;
1253 }
1254 }
1255 }
1256 }
1257 if deleted_json > 0 {
1258 repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1260 println!(
1261 " {} Removed {} stale .json duplicates (backed up as .json.bak)",
1262 "[OK]".green(),
1263 deleted_json
1264 );
1265 }
1266 }
1267
1268 let db_path = get_workspace_storage_db(&ws_id)?;
1270 println!(
1271 " {} Pass 4: Rebuilding model cache (agentSessions.model.cache)...",
1272 "[*]".cyan()
1273 );
1274 match read_chat_session_index(&db_path) {
1275 Ok(index) => match rebuild_model_cache(&db_path, &index) {
1276 Ok(n) => {
1277 println!(
1278 " {} Model cache rebuilt with {} entries",
1279 "[OK]".green(),
1280 n.to_string().cyan()
1281 );
1282 }
1283 Err(e) => {
1284 println!(
1285 " {} Failed to rebuild model cache: {}",
1286 "[WARN]".yellow(),
1287 e
1288 );
1289 }
1290 },
1291 Err(e) => {
1292 println!(
1293 " {} Failed to read index for model cache rebuild: {}",
1294 "[WARN]".yellow(),
1295 e
1296 );
1297 }
1298 }
1299
1300 println!(
1302 " {} Pass 5: Cleaning up state cache (agentSessions.state.cache)...",
1303 "[*]".cyan()
1304 );
1305 {
1306 let mut valid_ids: HashSet<String> = HashSet::new();
1308 if chat_sessions_dir.exists() {
1309 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1310 let entry = entry?;
1311 let p = entry.path();
1312 if p.extension().is_some_and(|e| e == "jsonl") {
1313 if let Some(stem) = p.file_stem() {
1314 valid_ids.insert(stem.to_string_lossy().to_string());
1315 }
1316 }
1317 }
1318 }
1319 match cleanup_state_cache(&db_path, &valid_ids) {
1320 Ok(0) => {
1321 println!(" {} State cache: all entries valid", "[OK]".green());
1322 }
1323 Ok(n) => {
1324 println!(
1325 " {} State cache: removed {} stale entries",
1326 "[OK]".green(),
1327 n.to_string().cyan()
1328 );
1329 }
1330 Err(e) => {
1331 println!(
1332 " {} Failed to cleanup state cache: {}",
1333 "[WARN]".yellow(),
1334 e
1335 );
1336 }
1337 }
1338
1339 println!(
1341 " {} Pass 6: Fixing session memento (last active session)...",
1342 "[*]".cyan()
1343 );
1344 let preferred_id = read_chat_session_index(&db_path).ok().and_then(|idx| {
1346 idx.entries
1347 .iter()
1348 .filter(|(_, e)| !e.is_empty)
1349 .max_by_key(|(_, e)| e.last_message_date)
1350 .map(|(id, _)| id.clone())
1351 });
1352 match fix_session_memento(&db_path, &valid_ids, preferred_id.as_deref()) {
1353 Ok(true) => {
1354 println!(
1355 " {} Memento updated to point to: {}",
1356 "[OK]".green(),
1357 preferred_id
1358 .as_deref()
1359 .unwrap_or("(first valid session)")
1360 .cyan()
1361 );
1362 }
1363 Ok(false) => {
1364 println!(
1365 " {} Memento already points to a valid session",
1366 "[OK]".green()
1367 );
1368 }
1369 Err(e) => {
1370 println!(" {} Failed to fix memento: {}", "[WARN]".yellow(), e);
1371 }
1372 }
1373 }
1374
1375 if reopen && vscode_was_running {
1377 println!(" {} Reopening VS Code...", "[*]".yellow());
1378 reopen_vscode(Some(&path_str))?;
1379 println!(
1380 " {} VS Code launched. Sessions should now load correctly.",
1381 "[OK]".green()
1382 );
1383 } else if should_close && vscode_was_running {
1384 println!(
1385 "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1386 "[!]".yellow()
1387 );
1388 println!(" Run: {}", format!("code {}", path.display()).cyan());
1389 }
1390
1391 Ok(())
1392}
1393
1394fn register_repair_recursive(
1396 root_path: Option<&str>,
1397 max_depth: Option<usize>,
1398 exclude_patterns: &[String],
1399 dry_run: bool,
1400 force: bool,
1401 close_vscode: bool,
1402 reopen: bool,
1403) -> Result<()> {
1404 let root = resolve_path(root_path);
1405 let should_close = close_vscode || reopen;
1406
1407 println!(
1408 "{} Recursively scanning for workspaces to repair from: {}",
1409 "[CSM]".cyan().bold(),
1410 root.display()
1411 );
1412
1413 if dry_run {
1414 println!("{} Dry run mode — no changes will be made", "[!]".yellow());
1415 }
1416
1417 let vscode_was_running = is_vscode_running();
1419 if vscode_was_running && !dry_run {
1420 if should_close {
1421 if !confirm_close_vscode(force) {
1422 println!("{} Aborted.", "[!]".yellow());
1423 return Ok(());
1424 }
1425 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
1426 close_vscode_and_wait(30)?;
1427 println!(" {} VS Code closed.\n", "[OK]".green());
1428 } else if !force {
1429 println!(
1430 "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1431 "[!]".yellow()
1432 );
1433 println!(
1434 " Use {} to close VS Code first, or {} to force.",
1435 "--reopen".cyan(),
1436 "--force".cyan()
1437 );
1438 return Err(CsmError::VSCodeRunning.into());
1439 }
1440 }
1441
1442 let workspaces = discover_workspaces()?;
1444 println!(
1445 " Found {} VS Code workspaces to check",
1446 workspaces.len().to_string().cyan()
1447 );
1448
1449 let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
1451 std::collections::HashMap::new();
1452 for ws in &workspaces {
1453 if let Some(ref project_path) = ws.project_path {
1454 let normalized = normalize_path(project_path);
1455 workspace_map.entry(normalized).or_default().push(ws);
1456 }
1457 }
1458
1459 let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
1461 .iter()
1462 .filter_map(|p| glob::Pattern::new(p).ok())
1463 .collect();
1464
1465 let default_excludes = [
1466 "node_modules",
1467 ".git",
1468 "target",
1469 "build",
1470 "dist",
1471 ".venv",
1472 "venv",
1473 "__pycache__",
1474 ".cache",
1475 "vendor",
1476 ".cargo",
1477 ];
1478
1479 let mut total_dirs_scanned = 0usize;
1480 let mut workspaces_found = 0usize;
1481 let mut total_compacted = 0usize;
1482 let mut total_synced = 0usize;
1483 let mut total_issues_found = 0usize;
1484 let mut total_issues_fixed = 0usize;
1485 let mut repair_results: Vec<(String, usize, bool, String)> = Vec::new(); fn walk_for_repair(
1489 dir: &Path,
1490 root: &Path,
1491 current_depth: usize,
1492 max_depth: Option<usize>,
1493 workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
1494 exclude_matchers: &[glob::Pattern],
1495 default_excludes: &[&str],
1496 dry_run: bool,
1497 force: bool,
1498 total_dirs_scanned: &mut usize,
1499 workspaces_found: &mut usize,
1500 total_compacted: &mut usize,
1501 total_synced: &mut usize,
1502 total_issues_found: &mut usize,
1503 total_issues_fixed: &mut usize,
1504 repair_results: &mut Vec<(String, usize, bool, String)>,
1505 ) -> Result<()> {
1506 if let Some(max) = max_depth {
1507 if current_depth > max {
1508 return Ok(());
1509 }
1510 }
1511
1512 *total_dirs_scanned += 1;
1513
1514 let normalized = normalize_path(&dir.to_string_lossy());
1516 if let Some(ws_list) = workspace_map.get(&normalized) {
1517 for ws in ws_list {
1518 if ws.has_chat_sessions && ws.chat_session_count > 0 {
1519 *workspaces_found += 1;
1520
1521 let display_name = ws.project_path.as_deref().unwrap_or(&ws.hash);
1522
1523 let chat_dir = ws.workspace_path.join("chatSessions");
1525 match crate::storage::diagnose_workspace_sessions(&ws.hash, &chat_dir) {
1526 Ok(diag) => {
1527 let issue_count = diag.issues.len();
1528 *total_issues_found += issue_count;
1529
1530 if issue_count == 0 {
1531 println!(
1532 " {} {} — {} sessions, healthy",
1533 "[OK]".green(),
1534 display_name.cyan(),
1535 ws.chat_session_count
1536 );
1537 repair_results.push((
1538 display_name.to_string(),
1539 0,
1540 true,
1541 "healthy".to_string(),
1542 ));
1543 } else {
1544 let issue_kinds: Vec<String> = {
1545 let mut kinds: Vec<String> = Vec::new();
1546 for issue in &diag.issues {
1547 let s = format!("{}", issue.kind);
1548 if !kinds.contains(&s) {
1549 kinds.push(s);
1550 }
1551 }
1552 kinds
1553 };
1554
1555 println!(
1556 " {} {} — {} sessions, {} issue(s): {}",
1557 "[!]".yellow(),
1558 display_name.cyan(),
1559 ws.chat_session_count,
1560 issue_count,
1561 issue_kinds.join(", ")
1562 );
1563
1564 if !dry_run {
1565 if let Some(ref project_path) = ws.project_path {
1567 match crate::workspace::recover_orphaned_sessions_from_old_hashes(project_path) {
1568 Ok(0) => {}
1569 Ok(n) => {
1570 println!(
1571 " {} Recovered {} orphaned session(s) from old hashes",
1572 "[OK]".green(),
1573 n
1574 );
1575 }
1576 Err(_) => {} }
1578 }
1579
1580 match repair_workspace_sessions(
1581 &ws.hash,
1582 &chat_dir,
1583 force || true,
1584 ) {
1585 Ok((compacted, synced)) => {
1586 *total_compacted += compacted;
1587 *total_synced += synced;
1588 *total_issues_fixed += issue_count;
1589
1590 let mut deleted_json = 0;
1592 let mut jsonl_sessions: HashSet<String> =
1593 HashSet::new();
1594 if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1595 for entry in entries.flatten() {
1596 let p = entry.path();
1597 if p.extension().is_some_and(|e| e == "jsonl") {
1598 if let Some(stem) = p.file_stem() {
1599 jsonl_sessions.insert(
1600 stem.to_string_lossy().to_string(),
1601 );
1602 }
1603 }
1604 }
1605 }
1606 if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1607 for entry in entries.flatten() {
1608 let p = entry.path();
1609 if p.extension().is_some_and(|e| e == "json") {
1610 if let Some(stem) = p.file_stem() {
1611 if jsonl_sessions.contains(
1612 &stem.to_string_lossy().to_string(),
1613 ) {
1614 let bak =
1615 p.with_extension("json.bak");
1616 let _ = std::fs::rename(&p, &bak);
1617 deleted_json += 1;
1618 }
1619 }
1620 }
1621 }
1622 }
1623 if deleted_json > 0 {
1624 let _ = repair_workspace_sessions(
1625 &ws.hash, &chat_dir, true,
1626 );
1627 }
1628
1629 let _ = repair_workspace_db_caches(
1631 &ws.hash, &chat_dir, false,
1632 );
1633
1634 let detail = format!(
1635 "{} compacted, {} synced{}",
1636 compacted,
1637 synced,
1638 if deleted_json > 0 {
1639 format!(
1640 ", {} stale .json backed up",
1641 deleted_json
1642 )
1643 } else {
1644 String::new()
1645 }
1646 );
1647 println!(" {} Fixed: {}", "[OK]".green(), detail);
1648 repair_results.push((
1649 display_name.to_string(),
1650 issue_count,
1651 true,
1652 detail,
1653 ));
1654 }
1655 Err(e) => {
1656 println!(" {} Failed: {}", "[ERR]".red(), e);
1657 repair_results.push((
1658 display_name.to_string(),
1659 issue_count,
1660 false,
1661 e.to_string(),
1662 ));
1663 }
1664 }
1665 } else {
1666 for issue in &diag.issues {
1668 println!(
1669 " {} {} — {}",
1670 "→".bright_black(),
1671 issue.session_id[..8.min(issue.session_id.len())]
1672 .to_string(),
1673 issue.kind
1674 );
1675 }
1676 repair_results.push((
1677 display_name.to_string(),
1678 issue_count,
1679 true,
1680 "dry run".to_string(),
1681 ));
1682 }
1683 }
1684 }
1685 Err(e) => {
1686 println!(" {} {} — scan failed: {}", "[ERR]".red(), display_name, e);
1687 }
1688 }
1689 }
1690 }
1691 }
1692
1693 let entries = match std::fs::read_dir(dir) {
1695 Ok(e) => e,
1696 Err(_) => return Ok(()),
1697 };
1698
1699 for entry in entries {
1700 let entry = match entry {
1701 Ok(e) => e,
1702 Err(_) => continue,
1703 };
1704 let path = entry.path();
1705 if !path.is_dir() {
1706 continue;
1707 }
1708
1709 let dir_name = entry.file_name().to_string_lossy().to_string();
1710
1711 if dir_name.starts_with('.') {
1713 continue;
1714 }
1715
1716 if default_excludes.iter().any(|e| dir_name == *e) {
1718 continue;
1719 }
1720
1721 if exclude_matchers.iter().any(|p| p.matches(&dir_name)) {
1723 continue;
1724 }
1725
1726 walk_for_repair(
1727 &path,
1728 root,
1729 current_depth + 1,
1730 max_depth,
1731 workspace_map,
1732 exclude_matchers,
1733 default_excludes,
1734 dry_run,
1735 force,
1736 total_dirs_scanned,
1737 workspaces_found,
1738 total_compacted,
1739 total_synced,
1740 total_issues_found,
1741 total_issues_fixed,
1742 repair_results,
1743 )?;
1744 }
1745
1746 Ok(())
1747 }
1748
1749 walk_for_repair(
1750 &root,
1751 &root,
1752 0,
1753 max_depth,
1754 &workspace_map,
1755 &exclude_matchers,
1756 &default_excludes,
1757 dry_run,
1758 force,
1759 &mut total_dirs_scanned,
1760 &mut workspaces_found,
1761 &mut total_compacted,
1762 &mut total_synced,
1763 &mut total_issues_found,
1764 &mut total_issues_fixed,
1765 &mut repair_results,
1766 )?;
1767
1768 println!("\n{}", "═".repeat(60).cyan());
1770 println!("{} Recursive repair scan complete", "[OK]".green().bold());
1771 println!("{}", "═".repeat(60).cyan());
1772 println!(
1773 " Directories scanned: {}",
1774 total_dirs_scanned.to_string().cyan()
1775 );
1776 println!(
1777 " Workspaces found: {}",
1778 workspaces_found.to_string().cyan()
1779 );
1780 println!(
1781 " Issues detected: {}",
1782 if total_issues_found > 0 {
1783 total_issues_found.to_string().yellow()
1784 } else {
1785 total_issues_found.to_string().green()
1786 }
1787 );
1788 if !dry_run {
1789 println!(
1790 " Issues fixed: {}",
1791 total_issues_fixed.to_string().green()
1792 );
1793 println!(
1794 " Files compacted: {}",
1795 total_compacted.to_string().cyan()
1796 );
1797 println!(
1798 " Index entries synced: {}",
1799 total_synced.to_string().cyan()
1800 );
1801 }
1802
1803 let failed_count = repair_results.iter().filter(|(_, _, ok, _)| !ok).count();
1804 if failed_count > 0 {
1805 println!(
1806 "\n {} {} workspace(s) had repair errors",
1807 "[!]".yellow(),
1808 failed_count.to_string().red()
1809 );
1810 }
1811
1812 if reopen && vscode_was_running {
1814 println!(" {} Reopening VS Code...", "[*]".yellow());
1815 reopen_vscode(None)?;
1816 println!(
1817 " {} VS Code launched. Sessions should now load correctly.",
1818 "[OK]".green()
1819 );
1820 } else if should_close && vscode_was_running {
1821 println!(
1822 "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1823 "[!]".yellow()
1824 );
1825 }
1826
1827 Ok(())
1828}
1829
1830fn repair_workspace_db_caches(
1838 workspace_id: &str,
1839 chat_sessions_dir: &Path,
1840 verbose: bool,
1841) -> Result<()> {
1842 let db_path = get_workspace_storage_db(workspace_id)?;
1843 if !db_path.exists() {
1844 return Ok(());
1845 }
1846
1847 match recover_from_json_bak(chat_sessions_dir) {
1849 Ok(0) => {}
1850 Ok(n) => {
1851 if verbose {
1852 println!(
1853 " {} Recovered {} session(s) from .json.bak",
1854 "[OK]".green(),
1855 n
1856 );
1857 }
1858 }
1859 Err(e) => {
1860 if verbose {
1861 println!(
1862 " {} .json.bak recovery failed: {}",
1863 "[WARN]".yellow(),
1864 e
1865 );
1866 }
1867 }
1868 }
1869
1870 match read_chat_session_index(&db_path) {
1872 Ok(index) => {
1873 if let Ok(n) = rebuild_model_cache(&db_path, &index) {
1874 if verbose && n > 0 {
1875 println!(
1876 " {} Model cache rebuilt ({} entries)",
1877 "[OK]".green(),
1878 n
1879 );
1880 }
1881 }
1882 }
1883 Err(_) => {}
1884 }
1885
1886 let mut valid_ids: HashSet<String> = HashSet::new();
1888 if chat_sessions_dir.exists() {
1889 if let Ok(entries) = std::fs::read_dir(chat_sessions_dir) {
1890 for entry in entries.flatten() {
1891 let p = entry.path();
1892 if p.extension().is_some_and(|e| e == "jsonl") {
1893 if let Some(stem) = p.file_stem() {
1894 valid_ids.insert(stem.to_string_lossy().to_string());
1895 }
1896 }
1897 }
1898 }
1899 }
1900
1901 match cleanup_state_cache(&db_path, &valid_ids) {
1903 Ok(n) if n > 0 && verbose => {
1904 println!(
1905 " {} State cache: removed {} stale entries",
1906 "[OK]".green(),
1907 n
1908 );
1909 }
1910 _ => {}
1911 }
1912
1913 let preferred_id = read_chat_session_index(&db_path).ok().and_then(|idx| {
1915 idx.entries
1916 .iter()
1917 .filter(|(_, e)| !e.is_empty)
1918 .max_by_key(|(_, e)| e.last_message_date)
1919 .map(|(id, _)| id.clone())
1920 });
1921 match fix_session_memento(&db_path, &valid_ids, preferred_id.as_deref()) {
1922 Ok(true) if verbose => {
1923 println!(
1924 " {} Memento updated to: {}",
1925 "[OK]".green(),
1926 preferred_id.as_deref().unwrap_or("(first valid)"),
1927 );
1928 }
1929 _ => {}
1930 }
1931
1932 Ok(())
1933}
1934
1935fn register_repair_all(force: bool, close_vscode: bool, reopen: bool) -> Result<()> {
1937 let should_close = close_vscode || reopen;
1938
1939 println!(
1940 "{} Repairing all workspaces with chat sessions...\n",
1941 "[CSM]".cyan().bold(),
1942 );
1943
1944 let vscode_was_running = is_vscode_running();
1946 if vscode_was_running {
1947 if should_close {
1948 if !confirm_close_vscode(force) {
1949 println!("{} Aborted.", "[!]".yellow());
1950 return Ok(());
1951 }
1952 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
1953 close_vscode_and_wait(30)?;
1954 println!(" {} VS Code closed.\n", "[OK]".green());
1955 } else if !force {
1956 println!(
1957 "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1958 "[!]".yellow()
1959 );
1960 println!(
1961 " Use {} to close VS Code first, or {} to force.",
1962 "--reopen".cyan(),
1963 "--force".cyan()
1964 );
1965 return Err(CsmError::VSCodeRunning.into());
1966 }
1967 }
1968
1969 let workspaces = discover_workspaces()?;
1970 let ws_with_sessions: Vec<_> = workspaces
1971 .iter()
1972 .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
1973 .collect();
1974
1975 if ws_with_sessions.is_empty() {
1976 println!("{} No workspaces with chat sessions found.", "[!]".yellow());
1977 return Ok(());
1978 }
1979
1980 println!(
1981 " Found {} workspaces with chat sessions\n",
1982 ws_with_sessions.len().to_string().cyan()
1983 );
1984
1985 let mut total_compacted = 0usize;
1986 let mut total_synced = 0usize;
1987 let mut succeeded = 0usize;
1988 let mut failed = 0usize;
1989
1990 for (i, ws) in ws_with_sessions.iter().enumerate() {
1991 let display_name = ws.project_path.as_deref().unwrap_or(&ws.hash);
1992 println!(
1993 "[{}/{}] {} {}",
1994 i + 1,
1995 ws_with_sessions.len(),
1996 "===".dimmed(),
1997 display_name.cyan()
1998 );
1999
2000 let chat_sessions_dir = ws.workspace_path.join("chatSessions");
2001 if !chat_sessions_dir.exists() {
2002 println!(
2003 " {} No chatSessions directory, skipping.\n",
2004 "[!]".yellow()
2005 );
2006 continue;
2007 }
2008
2009 match repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true) {
2010 Ok((compacted, index_fixed)) => {
2011 let mut deleted_json = 0;
2013 let mut jsonl_sessions: HashSet<String> = HashSet::new();
2014 for entry in std::fs::read_dir(&chat_sessions_dir)? {
2015 let entry = entry?;
2016 let p = entry.path();
2017 if p.extension().is_some_and(|e| e == "jsonl") {
2018 if let Some(stem) = p.file_stem() {
2019 jsonl_sessions.insert(stem.to_string_lossy().to_string());
2020 }
2021 }
2022 }
2023 for entry in std::fs::read_dir(&chat_sessions_dir)? {
2024 let entry = entry?;
2025 let p = entry.path();
2026 if p.extension().is_some_and(|e| e == "json") {
2027 if let Some(stem) = p.file_stem() {
2028 if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
2029 let bak = p.with_extension("json.bak");
2030 std::fs::rename(&p, &bak)?;
2031 deleted_json += 1;
2032 }
2033 }
2034 }
2035 }
2036 if deleted_json > 0 {
2037 repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true)?;
2038 }
2039
2040 let _ = repair_workspace_db_caches(&ws.hash, &chat_sessions_dir, false);
2042
2043 total_compacted += compacted;
2044 total_synced += index_fixed;
2045 succeeded += 1;
2046 println!(
2047 " {} {} compacted, {} synced{}\n",
2048 "[OK]".green(),
2049 compacted,
2050 index_fixed,
2051 if deleted_json > 0 {
2052 format!(", {} stale .json backed up", deleted_json)
2053 } else {
2054 String::new()
2055 }
2056 );
2057 }
2058 Err(e) => {
2059 failed += 1;
2060 println!(" {} {}\n", "[ERR]".red(), e);
2061 }
2062 }
2063 }
2064
2065 println!(
2066 "{} Repair complete: {}/{} workspaces, {} compacted, {} index entries synced",
2067 "[OK]".green().bold(),
2068 succeeded.to_string().green(),
2069 ws_with_sessions.len(),
2070 total_compacted.to_string().cyan(),
2071 total_synced.to_string().cyan()
2072 );
2073 if failed > 0 {
2074 println!(
2075 " {} {} workspace(s) had errors",
2076 "[!]".yellow(),
2077 failed.to_string().red()
2078 );
2079 }
2080
2081 if reopen && vscode_was_running {
2083 println!(" {} Reopening VS Code...", "[*]".yellow());
2084 reopen_vscode(None)?;
2085 println!(
2086 " {} VS Code launched. Sessions should now load correctly.",
2087 "[OK]".green()
2088 );
2089 } else if should_close && vscode_was_running {
2090 println!(
2091 "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
2092 "[!]".yellow()
2093 );
2094 }
2095
2096 Ok(())
2097}
2098
2099pub fn register_trim(
2106 project_path: Option<&str>,
2107 keep: usize,
2108 session_id: Option<&str>,
2109 all: bool,
2110 threshold_mb: u64,
2111 force: bool,
2112) -> Result<()> {
2113 let path = resolve_path(project_path);
2114
2115 println!(
2116 "{} Trimming oversized sessions for: {}",
2117 "[CSM]".cyan().bold(),
2118 path.display()
2119 );
2120
2121 let path_str = path.to_string_lossy().to_string();
2123 let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
2124 .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
2125
2126 let chat_sessions_dir = ws_path.join("chatSessions");
2127
2128 if !chat_sessions_dir.exists() {
2129 println!(
2130 "{} No chatSessions directory found at: {}",
2131 "[!]".yellow(),
2132 chat_sessions_dir.display()
2133 );
2134 return Ok(());
2135 }
2136
2137 if !force && is_vscode_running() {
2139 println!(
2140 "{} VS Code is running. Use {} to force.",
2141 "[!]".yellow(),
2142 "--force".cyan()
2143 );
2144 return Err(CsmError::VSCodeRunning.into());
2145 }
2146
2147 let mut trimmed_count = 0;
2148
2149 if let Some(sid) = session_id {
2150 let jsonl_path = chat_sessions_dir.join(format!("{}.jsonl", sid));
2152 if !jsonl_path.exists() {
2153 return Err(
2154 CsmError::InvalidSessionFormat(format!("Session not found: {}", sid)).into(),
2155 );
2156 }
2157
2158 let size_mb = std::fs::metadata(&jsonl_path)?.len() / (1024 * 1024);
2159 println!(
2160 " {} Trimming {} ({}MB, keeping last {} requests)...",
2161 "[*]".cyan(),
2162 sid,
2163 size_mb,
2164 keep
2165 );
2166
2167 match trim_session_jsonl(&jsonl_path, keep) {
2168 Ok((orig, kept, orig_mb, new_mb)) => {
2169 println!(
2170 " {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
2171 "[OK]".green(),
2172 orig,
2173 kept,
2174 orig_mb,
2175 new_mb
2176 );
2177 trimmed_count += 1;
2178 }
2179 Err(e) => {
2180 println!(" {} Failed to trim {}: {}", "[ERR]".red(), sid, e);
2181 }
2182 }
2183 } else if all {
2184 for entry in std::fs::read_dir(&chat_sessions_dir)? {
2186 let entry = entry?;
2187 let p = entry.path();
2188 if p.extension().is_some_and(|e| e == "jsonl") {
2189 let size = std::fs::metadata(&p)?.len();
2190 let size_mb_val = size / (1024 * 1024);
2191
2192 if size_mb_val >= threshold_mb {
2193 let stem = p
2194 .file_stem()
2195 .map(|s| s.to_string_lossy().to_string())
2196 .unwrap_or_default();
2197 println!(
2198 " {} Trimming {} ({}MB, keeping last {} requests)...",
2199 "[*]".cyan(),
2200 stem,
2201 size_mb_val,
2202 keep
2203 );
2204
2205 match trim_session_jsonl(&p, keep) {
2206 Ok((orig, kept, orig_mb, new_mb)) => {
2207 println!(
2208 " {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
2209 "[OK]".green(),
2210 orig,
2211 kept,
2212 orig_mb,
2213 new_mb
2214 );
2215 trimmed_count += 1;
2216 }
2217 Err(e) => {
2218 println!(" {} Failed to trim {}: {}", "[WARN]".yellow(), stem, e);
2219 }
2220 }
2221 }
2222 }
2223 }
2224 } else {
2225 let mut largest: Option<(PathBuf, u64)> = None;
2227
2228 for entry in std::fs::read_dir(&chat_sessions_dir)? {
2229 let entry = entry?;
2230 let p = entry.path();
2231 if p.extension().is_some_and(|e| e == "jsonl") {
2232 let size = std::fs::metadata(&p)?.len();
2233 let size_mb_val = size / (1024 * 1024);
2234
2235 if size_mb_val >= threshold_mb {
2236 if largest.as_ref().map_or(true, |(_, s)| size > *s) {
2237 largest = Some((p, size));
2238 }
2239 }
2240 }
2241 }
2242
2243 match largest {
2244 Some((p, size)) => {
2245 let stem = p
2246 .file_stem()
2247 .map(|s| s.to_string_lossy().to_string())
2248 .unwrap_or_default();
2249 let size_mb_val = size / (1024 * 1024);
2250 println!(
2251 " {} Trimming largest session: {} ({}MB, keeping last {} requests)...",
2252 "[*]".cyan(),
2253 stem,
2254 size_mb_val,
2255 keep
2256 );
2257
2258 match trim_session_jsonl(&p, keep) {
2259 Ok((orig, kept, orig_mb, new_mb)) => {
2260 println!(
2261 " {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
2262 "[OK]".green(),
2263 orig,
2264 kept,
2265 orig_mb,
2266 new_mb
2267 );
2268 trimmed_count += 1;
2269 }
2270 Err(e) => {
2271 println!(" {} Failed to trim: {}", "[ERR]".red(), e);
2272 }
2273 }
2274 }
2275 None => {
2276 println!(
2277 " {} No sessions found over {}MB threshold. Use {} to lower the threshold.",
2278 "[*]".cyan(),
2279 threshold_mb,
2280 "--threshold-mb".cyan()
2281 );
2282 }
2283 }
2284 }
2285
2286 if trimmed_count > 0 {
2287 let _ = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true);
2289 println!(
2290 "\n{} Trim complete: {} session(s) trimmed. Full history backed up as .jsonl.bak",
2291 "[OK]".green().bold(),
2292 trimmed_count.to_string().cyan()
2293 );
2294 }
2295
2296 Ok(())
2297}