Skip to main content

chasm/commands/
register.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Register commands - Add sessions to VS Code's session index
4//!
5//! VS Code only displays sessions that are registered in the `chat.ChatSessionStore.index`
6//! stored in `state.vscdb`. Sessions can exist on disk but be invisible to VS Code if
7//! they're not in this index. These commands help register orphaned sessions.
8
9use 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
29/// Prompt the user to confirm closing VS Code. Returns true if confirmed, false if declined.
30/// When `force` is true, skips the prompt and returns true immediately.
31fn 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
47/// Resolve a path option to an absolute PathBuf, handling "." and relative paths
48pub 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
58/// Register all sessions from a workspace into VS Code's index
59pub fn register_all(
60    project_path: Option<&str>,
61    merge: bool,
62    force: bool,
63    close_vscode: bool,
64    reopen: bool,
65) -> Result<()> {
66    let path = resolve_path(project_path);
67    // --reopen implies --close-vscode
68    let should_close = close_vscode || reopen;
69
70    if merge {
71        println!(
72            "{} Merging and registering all sessions for: {}",
73            "[CSM]".cyan().bold(),
74            path.display()
75        );
76
77        // Use the existing merge functionality
78        let path_str = path.to_string_lossy().to_string();
79        return crate::commands::history_merge(
80            Some(&path_str),
81            None,  // title
82            force, // force
83            false, // no_backup
84        );
85    }
86
87    println!(
88        "{} Registering all sessions for: {}",
89        "[CSM]".cyan().bold(),
90        path.display()
91    );
92
93    // Find the workspace
94    let path_str = path.to_string_lossy().to_string();
95    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
96        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
97
98    let chat_sessions_dir = ws_path.join("chatSessions");
99
100    if !chat_sessions_dir.exists() {
101        println!(
102            "{} No chatSessions directory found at: {}",
103            "[!]".yellow(),
104            chat_sessions_dir.display()
105        );
106        return Ok(());
107    }
108
109    // Handle VS Code lifecycle
110    let vscode_was_running = is_vscode_running();
111    if vscode_was_running {
112        if should_close {
113            if !confirm_close_vscode(force) {
114                println!("{} Aborted.", "[!]".yellow());
115                return Ok(());
116            }
117            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
118            close_vscode_and_wait(30)?;
119            println!("   {} VS Code closed.", "[OK]".green());
120        } else if !force {
121            println!(
122                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
123                "[!]".yellow()
124            );
125            println!(
126                "   Use {} to close VS Code first, register, and reopen.",
127                "--reopen".cyan()
128            );
129            println!(
130                "   Use {} to just close VS Code first.",
131                "--close-vscode".cyan()
132            );
133            println!(
134                "   Use {} to write anyway (works after restarting VS Code).",
135                "--force".cyan()
136            );
137            return Err(CsmError::VSCodeRunning.into());
138        }
139    }
140
141    // Count sessions on disk
142    let sessions_on_disk = count_sessions_in_directory(&chat_sessions_dir)?;
143    println!(
144        "   Found {} session files on disk",
145        sessions_on_disk.to_string().green()
146    );
147
148    // Register all sessions
149    let registered = register_all_sessions_from_directory(&ws_id, &chat_sessions_dir, true)?;
150
151    println!(
152        "\n{} Registered {} sessions in VS Code's index",
153        "[OK]".green().bold(),
154        registered.to_string().cyan()
155    );
156
157    // Reopen VS Code if requested (or if we closed it with --reopen)
158    if reopen && vscode_was_running {
159        println!("   {} Reopening VS Code...", "[*]".yellow());
160        reopen_vscode(Some(&path_str))?;
161        println!(
162            "   {} VS Code launched. Sessions should appear in Copilot Chat history.",
163            "[OK]".green()
164        );
165    } else if should_close && vscode_was_running {
166        println!(
167            "\n{} VS Code was closed. Reopen it to see the recovered sessions.",
168            "[!]".yellow()
169        );
170        println!("   Run: {}", format!("code {}", path.display()).cyan());
171    } else if force && vscode_was_running {
172        // VS Code is still running with --force, show reload instructions
173        println!(
174            "\n{} VS Code caches the session index in memory.",
175            "[!]".yellow()
176        );
177        println!("   To see the new sessions, do one of the following:");
178        println!(
179            "   * Press {} and run {}",
180            "Ctrl+Shift+P".cyan(),
181            "Developer: Reload Window".cyan()
182        );
183        println!("   * Or restart VS Code");
184    }
185
186    Ok(())
187}
188
189/// Register specific sessions by ID or title
190pub fn register_sessions(
191    ids: &[String],
192    titles: Option<&[String]>,
193    project_path: Option<&str>,
194    force: bool,
195) -> Result<()> {
196    let path = resolve_path(project_path);
197
198    // Find the workspace
199    let path_str = path.to_string_lossy().to_string();
200    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
201        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
202
203    let chat_sessions_dir = ws_path.join("chatSessions");
204
205    // Check if VS Code is running
206    if !force && is_vscode_running() {
207        println!(
208            "{} VS Code is running. Use {} to register anyway.",
209            "[!]".yellow(),
210            "--force".cyan()
211        );
212        return Err(CsmError::VSCodeRunning.into());
213    }
214
215    // Get the database path
216    let db_path = get_workspace_storage_db(&ws_id)?;
217
218    let mut registered_count = 0;
219
220    if let Some(titles) = titles {
221        // Register by title
222        println!(
223            "{} Registering {} sessions by title:",
224            "[CSM]".cyan().bold(),
225            titles.len()
226        );
227
228        let sessions = find_sessions_by_titles(&chat_sessions_dir, titles)?;
229
230        for (session, session_path) in sessions {
231            let session_id = session.session_id.clone().unwrap_or_else(|| {
232                session_path
233                    .file_stem()
234                    .map(|s| s.to_string_lossy().to_string())
235                    .unwrap_or_default()
236            });
237            let title = session.title();
238
239            add_session_to_index(
240                &db_path,
241                &session_id,
242                &title,
243                session.last_message_date,
244                session.is_imported,
245                &session.initial_location,
246                session.is_empty(),
247            )?;
248
249            let id_display = if session_id.len() > 12 {
250                &session_id[..12]
251            } else {
252                &session_id
253            };
254            println!(
255                "   {} {} (\"{}\")",
256                "[OK]".green(),
257                id_display.cyan(),
258                title.yellow()
259            );
260            registered_count += 1;
261        }
262    } else {
263        // Register by ID (default)
264        println!(
265            "{} Registering {} sessions by ID:",
266            "[CSM]".cyan().bold(),
267            ids.len()
268        );
269
270        for session_id in ids {
271            match find_session_file(&chat_sessions_dir, session_id) {
272                Ok(session_file) => {
273                    let session = parse_session_file(&session_file)?;
274
275                    let title = session.title();
276                    let actual_session_id = session
277                        .session_id
278                        .clone()
279                        .unwrap_or_else(|| session_id.to_string());
280
281                    add_session_to_index(
282                        &db_path,
283                        &actual_session_id,
284                        &title,
285                        session.last_message_date,
286                        session.is_imported,
287                        &session.initial_location,
288                        session.is_empty(),
289                    )?;
290
291                    let id_display = if actual_session_id.len() > 12 {
292                        &actual_session_id[..12]
293                    } else {
294                        &actual_session_id
295                    };
296                    println!(
297                        "   {} {} (\"{}\")",
298                        "[OK]".green(),
299                        id_display.cyan(),
300                        title.yellow()
301                    );
302                    registered_count += 1;
303                }
304                Err(e) => {
305                    println!(
306                        "   {} {} - {}",
307                        "[ERR]".red(),
308                        session_id.cyan(),
309                        e.to_string().red()
310                    );
311                }
312            }
313        }
314    }
315
316    println!(
317        "\n{} Registered {} sessions in VS Code's index",
318        "[OK]".green().bold(),
319        registered_count.to_string().cyan()
320    );
321
322    if force && is_vscode_running() {
323        println!(
324            "   {} Sessions should appear in VS Code immediately",
325            "->".cyan()
326        );
327    }
328
329    Ok(())
330}
331
332/// List sessions that exist on disk but are not in VS Code's index
333pub fn list_orphaned(project_path: Option<&str>) -> Result<()> {
334    let path = resolve_path(project_path);
335
336    println!(
337        "{} Finding orphaned sessions for: {}",
338        "[CSM]".cyan().bold(),
339        path.display()
340    );
341
342    // Find the workspace
343    let path_str = path.to_string_lossy().to_string();
344    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
345        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
346
347    let chat_sessions_dir = ws_path.join("chatSessions");
348
349    if !chat_sessions_dir.exists() {
350        println!("{} No chatSessions directory found", "[!]".yellow());
351        return Ok(());
352    }
353
354    // Get sessions currently in the index
355    let db_path = get_workspace_storage_db(&ws_id)?;
356    let index = read_chat_session_index(&db_path)?;
357    let indexed_ids: HashSet<String> = index.entries.keys().cloned().collect();
358
359    println!(
360        "   {} sessions currently in VS Code's index",
361        indexed_ids.len().to_string().cyan()
362    );
363
364    // Find sessions on disk
365    let mut orphaned_sessions = Vec::new();
366
367    // Collect files, preferring .jsonl over .json for the same session ID
368    let mut session_files: std::collections::HashMap<String, PathBuf> =
369        std::collections::HashMap::new();
370    for entry in std::fs::read_dir(&chat_sessions_dir)? {
371        let entry = entry?;
372        let path = entry.path();
373        if path
374            .extension()
375            .map(is_session_file_extension)
376            .unwrap_or(false)
377        {
378            if let Some(stem) = path.file_stem() {
379                let stem_str = stem.to_string_lossy().to_string();
380                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
381                if !session_files.contains_key(&stem_str) || is_jsonl {
382                    session_files.insert(stem_str, path);
383                }
384            }
385        }
386    }
387
388    for (_, path) in &session_files {
389        if let Ok(session) = parse_session_file(path) {
390            let session_id = session.session_id.clone().unwrap_or_else(|| {
391                path.file_stem()
392                    .map(|s| s.to_string_lossy().to_string())
393                    .unwrap_or_default()
394            });
395
396            if !indexed_ids.contains(&session_id) {
397                let title = session.title();
398                let msg_count = session.requests.len();
399                orphaned_sessions.push((session_id, title, msg_count, path.clone()));
400            }
401        }
402    }
403
404    if orphaned_sessions.is_empty() {
405        println!(
406            "\n{} No orphaned sessions found - all sessions are registered!",
407            "[OK]".green().bold()
408        );
409        return Ok(());
410    }
411
412    println!(
413        "\n{} Found {} orphaned sessions (on disk but not in index):\n",
414        "[!]".yellow().bold(),
415        orphaned_sessions.len().to_string().red()
416    );
417
418    for (session_id, title, msg_count, _path) in &orphaned_sessions {
419        let id_display = if session_id.len() > 12 {
420            &session_id[..12]
421        } else {
422            session_id
423        };
424        println!(
425            "   {} {} ({} messages)",
426            id_display.cyan(),
427            format!("\"{}\"", title).yellow(),
428            msg_count
429        );
430    }
431
432    println!("\n{} To register all orphaned sessions:", "->".cyan());
433    println!("   csm register all --force");
434    println!("\n{} To register specific sessions:", "->".cyan());
435    println!("   csm register session <ID1> <ID2> ... --force");
436
437    Ok(())
438}
439
440/// Count session files in a directory (counts unique session IDs, preferring .jsonl)
441fn count_sessions_in_directory(dir: &PathBuf) -> Result<usize> {
442    let mut session_ids: HashSet<String> = HashSet::new();
443    for entry in std::fs::read_dir(dir)? {
444        let entry = entry?;
445        let path = entry.path();
446        if path
447            .extension()
448            .map(is_session_file_extension)
449            .unwrap_or(false)
450        {
451            if let Some(stem) = path.file_stem() {
452                session_ids.insert(stem.to_string_lossy().to_string());
453            }
454        }
455    }
456    Ok(session_ids.len())
457}
458
459/// Find a session file by ID (supports partial matches, prefers .jsonl over .json)
460fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
461    // First try exact match (.jsonl preferred)
462    let exact_jsonl = chat_sessions_dir.join(format!("{}.jsonl", session_id));
463    if exact_jsonl.exists() {
464        return Ok(exact_jsonl);
465    }
466    let exact_json = chat_sessions_dir.join(format!("{}.json", session_id));
467    if exact_json.exists() {
468        return Ok(exact_json);
469    }
470
471    // Try partial match (prefix), preferring .jsonl
472    let mut best_match: Option<PathBuf> = None;
473    for entry in std::fs::read_dir(chat_sessions_dir)? {
474        let entry = entry?;
475        let path = entry.path();
476
477        if path
478            .extension()
479            .map(is_session_file_extension)
480            .unwrap_or(false)
481        {
482            let filename = path
483                .file_stem()
484                .map(|s| s.to_string_lossy().to_string())
485                .unwrap_or_default();
486
487            if filename.starts_with(session_id) {
488                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
489                if best_match.is_none() || is_jsonl {
490                    best_match = Some(path.clone());
491                    if is_jsonl {
492                        return Ok(path);
493                    }
494                }
495                continue;
496            }
497
498            // Also check session_id inside the file
499            if let Ok(session) = parse_session_file(&path) {
500                if let Some(ref sid) = session.session_id {
501                    if sid.starts_with(session_id) || sid == session_id {
502                        let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
503                        if best_match.is_none() || is_jsonl {
504                            best_match = Some(path.clone());
505                        }
506                    }
507                }
508            }
509        }
510    }
511
512    best_match.ok_or_else(|| CsmError::SessionNotFound(session_id.to_string()).into())
513}
514
515/// Find sessions by title (case-insensitive partial match)
516fn find_sessions_by_titles(
517    chat_sessions_dir: &PathBuf,
518    titles: &[String],
519) -> Result<Vec<(ChatSession, PathBuf)>> {
520    let mut matches = Vec::new();
521    let title_patterns: Vec<String> = titles.iter().map(|t| t.to_lowercase()).collect();
522
523    // Collect files, preferring .jsonl over .json for the same session ID
524    let mut session_files: std::collections::HashMap<String, PathBuf> =
525        std::collections::HashMap::new();
526    for entry in std::fs::read_dir(chat_sessions_dir)? {
527        let entry = entry?;
528        let path = entry.path();
529        if path
530            .extension()
531            .map(is_session_file_extension)
532            .unwrap_or(false)
533        {
534            if let Some(stem) = path.file_stem() {
535                let stem_str = stem.to_string_lossy().to_string();
536                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
537                if !session_files.contains_key(&stem_str) || is_jsonl {
538                    session_files.insert(stem_str, path);
539                }
540            }
541        }
542    }
543
544    for (_, path) in &session_files {
545        if let Ok(session) = parse_session_file(path) {
546            let session_title = session.title().to_lowercase();
547
548            for pattern in &title_patterns {
549                if session_title.contains(pattern) {
550                    matches.push((session, path.clone()));
551                    break;
552                }
553            }
554        }
555    }
556
557    if matches.is_empty() {
558        println!(
559            "{} No sessions found matching the specified titles",
560            "[!]".yellow()
561        );
562    }
563
564    Ok(matches)
565}
566
567/// Recursively walk directories and register orphaned sessions for all workspaces found
568pub fn register_recursive(
569    root_path: Option<&str>,
570    max_depth: Option<usize>,
571    force: bool,
572    dry_run: bool,
573    exclude_patterns: &[String],
574) -> Result<()> {
575    let root = resolve_path(root_path);
576
577    println!(
578        "{} Scanning for workspaces recursively from: {}",
579        "[CSM]".cyan().bold(),
580        root.display()
581    );
582
583    if dry_run {
584        println!("{} Dry run mode - no changes will be made", "[!]".yellow());
585    }
586
587    // Check if VS Code is running
588    if !force && !dry_run && is_vscode_running() {
589        println!(
590            "{} VS Code is running. Use {} to register anyway.",
591            "[!]".yellow(),
592            "--force".cyan()
593        );
594        println!("   Note: VS Code uses WAL mode so this is generally safe.");
595        return Err(CsmError::VSCodeRunning.into());
596    }
597
598    // Get all VS Code workspaces
599    let workspaces = discover_workspaces()?;
600    println!(
601        "   Found {} VS Code workspaces to check",
602        workspaces.len().to_string().cyan()
603    );
604
605    // Build a map of normalized project paths to workspace info
606    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
607        std::collections::HashMap::new();
608    for ws in &workspaces {
609        if let Some(ref project_path) = ws.project_path {
610            let normalized = normalize_path(project_path);
611            workspace_map.entry(normalized).or_default().push(ws);
612        }
613    }
614
615    // Compile exclude patterns
616    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
617        .iter()
618        .filter_map(|p| glob::Pattern::new(p).ok())
619        .collect();
620
621    // Default exclusions for common non-project directories
622    let default_excludes = [
623        "node_modules",
624        ".git",
625        "target",
626        "build",
627        "dist",
628        ".venv",
629        "venv",
630        "__pycache__",
631        ".cache",
632        "vendor",
633        ".cargo",
634    ];
635
636    let mut total_dirs_scanned = 0;
637    let mut workspaces_found = 0;
638    let mut total_sessions_registered = 0;
639    let mut workspaces_with_orphans: Vec<(String, usize, usize)> = Vec::new();
640
641    // Walk the directory tree
642    walk_directory(
643        &root,
644        &root,
645        0,
646        max_depth,
647        &workspace_map,
648        &exclude_matchers,
649        &default_excludes,
650        force,
651        dry_run,
652        &mut total_dirs_scanned,
653        &mut workspaces_found,
654        &mut total_sessions_registered,
655        &mut workspaces_with_orphans,
656    )?;
657
658    // Print summary
659    println!("\n{}", "═".repeat(60).cyan());
660    println!("{} Recursive scan complete", "[OK]".green().bold());
661    println!("{}", "═".repeat(60).cyan());
662    println!(
663        "   Directories scanned:    {}",
664        total_dirs_scanned.to_string().cyan()
665    );
666    println!(
667        "   Workspaces found:       {}",
668        workspaces_found.to_string().cyan()
669    );
670    println!(
671        "   Sessions registered:    {}",
672        total_sessions_registered.to_string().green()
673    );
674
675    if !workspaces_with_orphans.is_empty() {
676        println!("\n   {} Workspaces with orphaned sessions:", "[+]".green());
677        for (path, orphaned, registered) in &workspaces_with_orphans {
678            let reg_str = if dry_run {
679                format!("would register {}", registered)
680            } else {
681                format!("registered {}", registered)
682            };
683            println!(
684                "      {} ({} orphaned, {})",
685                path.cyan(),
686                orphaned.to_string().yellow(),
687                reg_str.green()
688            );
689        }
690    }
691
692    if total_sessions_registered > 0 && !dry_run {
693        println!(
694            "\n{} VS Code caches the session index in memory.",
695            "[!]".yellow()
696        );
697        println!("   To see the new sessions, do one of the following:");
698        println!(
699            "   * Run: {} (if CSM extension is installed)",
700            "code --command csm.reloadAndShowChats".cyan()
701        );
702        println!(
703            "   * Or press {} in VS Code and run {}",
704            "Ctrl+Shift+P".cyan(),
705            "Developer: Reload Window".cyan()
706        );
707        println!("   * Or restart VS Code");
708    }
709
710    Ok(())
711}
712
713/// Recursively walk a directory and process workspaces
714#[allow(clippy::too_many_arguments)]
715fn walk_directory(
716    current_dir: &Path,
717    root: &Path,
718    current_depth: usize,
719    max_depth: Option<usize>,
720    workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
721    exclude_matchers: &[glob::Pattern],
722    default_excludes: &[&str],
723    force: bool,
724    dry_run: bool,
725    total_dirs_scanned: &mut usize,
726    workspaces_found: &mut usize,
727    total_sessions_registered: &mut usize,
728    workspaces_with_orphans: &mut Vec<(String, usize, usize)>,
729) -> Result<()> {
730    // Check depth limit
731    if let Some(max) = max_depth {
732        if current_depth > max {
733            return Ok(());
734        }
735    }
736
737    *total_dirs_scanned += 1;
738
739    // Get directory name for exclusion checking
740    let dir_name = current_dir
741        .file_name()
742        .map(|n| n.to_string_lossy().to_string())
743        .unwrap_or_default();
744
745    // Skip default excluded directories
746    if default_excludes.contains(&dir_name.as_str()) {
747        return Ok(());
748    }
749
750    // Skip if matches user exclusion patterns
751    let relative_path = current_dir
752        .strip_prefix(root)
753        .unwrap_or(current_dir)
754        .to_string_lossy();
755    for pattern in exclude_matchers {
756        if pattern.matches(&relative_path) || pattern.matches(&dir_name) {
757            return Ok(());
758        }
759    }
760
761    // Check if this directory is a VS Code workspace
762    let normalized_path = normalize_path(&current_dir.to_string_lossy());
763    if let Some(workspace_entries) = workspace_map.get(&normalized_path) {
764        *workspaces_found += 1;
765
766        for ws in workspace_entries {
767            // Check for orphaned sessions in this workspace
768            if ws.has_chat_sessions {
769                let chat_sessions_dir = &ws.chat_sessions_path;
770
771                // Count orphaned sessions
772                match count_orphaned_sessions(&ws.hash, chat_sessions_dir) {
773                    Ok((on_disk, in_index, orphaned_count)) => {
774                        if orphaned_count > 0 {
775                            let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
776
777                            if dry_run {
778                                println!(
779                                    "   {} {} - {} sessions on disk, {} in index, {} orphaned",
780                                    "[DRY]".yellow(),
781                                    display_path.cyan(),
782                                    on_disk.to_string().white(),
783                                    in_index.to_string().white(),
784                                    orphaned_count.to_string().yellow()
785                                );
786                                workspaces_with_orphans.push((
787                                    display_path.to_string(),
788                                    orphaned_count,
789                                    orphaned_count,
790                                ));
791                            } else {
792                                // Register the sessions
793                                match register_all_sessions_from_directory(
794                                    &ws.hash,
795                                    chat_sessions_dir,
796                                    force,
797                                ) {
798                                    Ok(registered) => {
799                                        *total_sessions_registered += registered;
800                                        println!(
801                                            "   {} {} - registered {} sessions",
802                                            "[+]".green(),
803                                            display_path.cyan(),
804                                            registered.to_string().green()
805                                        );
806                                        workspaces_with_orphans.push((
807                                            display_path.to_string(),
808                                            orphaned_count,
809                                            registered,
810                                        ));
811                                    }
812                                    Err(e) => {
813                                        println!(
814                                            "   {} {} - error: {}",
815                                            "[!]".red(),
816                                            display_path.cyan(),
817                                            e
818                                        );
819                                    }
820                                }
821                            }
822                        }
823                    }
824                    Err(e) => {
825                        let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
826                        println!(
827                            "   {} {} - error checking: {}",
828                            "[!]".yellow(),
829                            display_path,
830                            e
831                        );
832                    }
833                }
834            }
835        }
836    }
837
838    // Recurse into subdirectories
839    match std::fs::read_dir(current_dir) {
840        Ok(entries) => {
841            for entry in entries.flatten() {
842                let path = entry.path();
843                if path.is_dir() {
844                    // Skip hidden directories
845                    let name = path
846                        .file_name()
847                        .map(|n| n.to_string_lossy().to_string())
848                        .unwrap_or_default();
849                    if name.starts_with('.') {
850                        continue;
851                    }
852
853                    walk_directory(
854                        &path,
855                        root,
856                        current_depth + 1,
857                        max_depth,
858                        workspace_map,
859                        exclude_matchers,
860                        default_excludes,
861                        force,
862                        dry_run,
863                        total_dirs_scanned,
864                        workspaces_found,
865                        total_sessions_registered,
866                        workspaces_with_orphans,
867                    )?;
868                }
869            }
870        }
871        Err(e) => {
872            // Permission denied or other errors - skip silently
873            if e.kind() != std::io::ErrorKind::PermissionDenied {
874                eprintln!(
875                    "   {} Could not read {}: {}",
876                    "[!]".yellow(),
877                    current_dir.display(),
878                    e
879                );
880            }
881        }
882    }
883
884    Ok(())
885}
886
887/// Count orphaned sessions in a workspace (on disk but not in index)
888fn count_orphaned_sessions(
889    workspace_id: &str,
890    chat_sessions_dir: &Path,
891) -> Result<(usize, usize, usize)> {
892    // Get sessions in index
893    let db_path = get_workspace_storage_db(workspace_id)?;
894    let indexed_sessions = read_chat_session_index(&db_path)?;
895    let indexed_ids: HashSet<String> = indexed_sessions.entries.keys().cloned().collect();
896
897    // Count unique sessions on disk (preferring .jsonl over .json)
898    let mut disk_sessions: HashSet<String> = HashSet::new();
899
900    for entry in std::fs::read_dir(chat_sessions_dir)? {
901        let entry = entry?;
902        let path = entry.path();
903
904        if path
905            .extension()
906            .map(is_session_file_extension)
907            .unwrap_or(false)
908        {
909            if let Some(stem) = path.file_stem() {
910                disk_sessions.insert(stem.to_string_lossy().to_string());
911            }
912        }
913    }
914
915    let on_disk = disk_sessions.len();
916    let orphaned = disk_sessions
917        .iter()
918        .filter(|id| !indexed_ids.contains(*id))
919        .count();
920
921    Ok((on_disk, indexed_ids.len(), orphaned))
922}
923
924/// Repair sessions: compact JSONL files and rebuild the index with correct metadata
925pub fn register_repair(
926    project_path: Option<&str>,
927    all: bool,
928    recursive: bool,
929    max_depth: Option<usize>,
930    exclude_patterns: &[String],
931    dry_run: bool,
932    force: bool,
933    close_vscode: bool,
934    reopen: bool,
935) -> Result<()> {
936    if all {
937        return register_repair_all(force, close_vscode, reopen);
938    }
939
940    if recursive {
941        return register_repair_recursive(
942            project_path,
943            max_depth,
944            exclude_patterns,
945            dry_run,
946            force,
947            close_vscode,
948            reopen,
949        );
950    }
951
952    let path = resolve_path(project_path);
953    let should_close = close_vscode || reopen;
954
955    println!(
956        "{} Repairing sessions for: {}",
957        "[CSM]".cyan().bold(),
958        path.display()
959    );
960
961    // Find the workspace
962    let path_str = path.to_string_lossy().to_string();
963    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
964        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
965
966    let chat_sessions_dir = ws_path.join("chatSessions");
967
968    if !chat_sessions_dir.exists() {
969        println!(
970            "{} No chatSessions directory found at: {}",
971            "[!]".yellow(),
972            chat_sessions_dir.display()
973        );
974        return Ok(());
975    }
976
977    // Handle VS Code lifecycle
978    let vscode_was_running = is_vscode_running();
979    if vscode_was_running {
980        if should_close {
981            if !confirm_close_vscode(force) {
982                println!("{} Aborted.", "[!]".yellow());
983                return Ok(());
984            }
985            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
986            close_vscode_and_wait(30)?;
987            println!("   {} VS Code closed.", "[OK]".green());
988        } else if !force {
989            println!(
990                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
991                "[!]".yellow()
992            );
993            println!(
994                "   Use {} to close VS Code first, or {} to force.",
995                "--reopen".cyan(),
996                "--force".cyan()
997            );
998            return Err(CsmError::VSCodeRunning.into());
999        }
1000    }
1001
1002    // Pass 0: Recover orphaned sessions from old workspace hashes
1003    println!(
1004        "   {} Pass 0: Recovering orphaned sessions from old workspace hashes...",
1005        "[*]".cyan()
1006    );
1007    match recover_orphaned_sessions_from_old_hashes(&path_str) {
1008        Ok(0) => {}
1009        Ok(n) => {
1010            println!(
1011                "   {} Recovered {} orphaned session(s) from old workspace hashes",
1012                "[OK]".green(),
1013                n.to_string().cyan()
1014            );
1015        }
1016        Err(e) => {
1017            println!(
1018                "   {} Failed to recover orphaned sessions: {}",
1019                "[WARN]".yellow(),
1020                e
1021            );
1022        }
1023    }
1024
1025    // Run the repair
1026    // Pass 0.5: Recover from .json.bak when .jsonl has fewer requests (truncated migration)
1027    println!(
1028        "   {} Pass 0.5: Recovering sessions from .json.bak files...",
1029        "[*]".cyan()
1030    );
1031    match recover_from_json_bak(&chat_sessions_dir) {
1032        Ok(0) => {}
1033        Ok(n) => {
1034            println!(
1035                "   {} Recovered {} session(s) from .json.bak backups",
1036                "[OK]".green(),
1037                n.to_string().cyan()
1038            );
1039        }
1040        Err(e) => {
1041            println!(
1042                "   {} Failed to recover from .json.bak: {}",
1043                "[WARN]".yellow(),
1044                e
1045            );
1046        }
1047    }
1048
1049    println!(
1050        "   {} Pass 1: Compacting JSONL files & fixing compat fields...",
1051        "[*]".cyan()
1052    );
1053    println!(
1054        "   {} Pass 1.5: Converting skeleton .json files...",
1055        "[*]".cyan()
1056    );
1057    println!("   {} Pass 2: Fixing cancelled modelState...", "[*]".cyan());
1058    let (compacted, index_fixed) = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1059
1060    println!("   {} Pass 3: Index rebuilt.", "[*]".cyan());
1061    println!(
1062        "\n{} Repair complete: {} files compacted, {} index entries synced",
1063        "[OK]".green().bold(),
1064        compacted.to_string().cyan(),
1065        index_fixed.to_string().cyan()
1066    );
1067
1068    // Delete stale .json files when a .jsonl exists for the same session
1069    let mut deleted_json = 0;
1070    if chat_sessions_dir.exists() {
1071        let mut jsonl_sessions: HashSet<String> = HashSet::new();
1072        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1073            let entry = entry?;
1074            let p = entry.path();
1075            if p.extension().is_some_and(|e| e == "jsonl") {
1076                if let Some(stem) = p.file_stem() {
1077                    jsonl_sessions.insert(stem.to_string_lossy().to_string());
1078                }
1079            }
1080        }
1081        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1082            let entry = entry?;
1083            let p = entry.path();
1084            if p.extension().is_some_and(|e| e == "json") {
1085                if let Some(stem) = p.file_stem() {
1086                    if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1087                        // Rename to .json.bak to preserve as backup
1088                        let bak = p.with_extension("json.bak");
1089                        std::fs::rename(&p, &bak)?;
1090                        println!(
1091                            "   {} Backed up stale .json: {} → {}",
1092                            "[*]".yellow(),
1093                            p.file_name().unwrap_or_default().to_string_lossy(),
1094                            bak.file_name().unwrap_or_default().to_string_lossy()
1095                        );
1096                        deleted_json += 1;
1097                    }
1098                }
1099            }
1100        }
1101        if deleted_json > 0 {
1102            // Re-sync index after removing .json files
1103            repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1104            println!(
1105                "   {} Removed {} stale .json duplicates (backed up as .json.bak)",
1106                "[OK]".green(),
1107                deleted_json
1108            );
1109        }
1110    }
1111
1112    // Pass 4: Rebuild agentSessions.model.cache (makes sessions visible in Chat sidebar)
1113    let db_path = get_workspace_storage_db(&ws_id)?;
1114    println!(
1115        "   {} Pass 4: Rebuilding model cache (agentSessions.model.cache)...",
1116        "[*]".cyan()
1117    );
1118    match read_chat_session_index(&db_path) {
1119        Ok(index) => match rebuild_model_cache(&db_path, &index) {
1120            Ok(n) => {
1121                println!(
1122                    "   {} Model cache rebuilt with {} entries",
1123                    "[OK]".green(),
1124                    n.to_string().cyan()
1125                );
1126            }
1127            Err(e) => {
1128                println!(
1129                    "   {} Failed to rebuild model cache: {}",
1130                    "[WARN]".yellow(),
1131                    e
1132                );
1133            }
1134        },
1135        Err(e) => {
1136            println!(
1137                "   {} Failed to read index for model cache rebuild: {}",
1138                "[WARN]".yellow(),
1139                e
1140            );
1141        }
1142    }
1143
1144    // Pass 5: Cleanup agentSessions.state.cache (remove stale entries)
1145    println!(
1146        "   {} Pass 5: Cleaning up state cache (agentSessions.state.cache)...",
1147        "[*]".cyan()
1148    );
1149    {
1150        // Collect valid session IDs from disk
1151        let mut valid_ids: HashSet<String> = HashSet::new();
1152        if chat_sessions_dir.exists() {
1153            for entry in std::fs::read_dir(&chat_sessions_dir)? {
1154                let entry = entry?;
1155                let p = entry.path();
1156                if p.extension().is_some_and(|e| e == "jsonl") {
1157                    if let Some(stem) = p.file_stem() {
1158                        valid_ids.insert(stem.to_string_lossy().to_string());
1159                    }
1160                }
1161            }
1162        }
1163        match cleanup_state_cache(&db_path, &valid_ids) {
1164            Ok(0) => {
1165                println!("   {} State cache: all entries valid", "[OK]".green());
1166            }
1167            Ok(n) => {
1168                println!(
1169                    "   {} State cache: removed {} stale entries",
1170                    "[OK]".green(),
1171                    n.to_string().cyan()
1172                );
1173            }
1174            Err(e) => {
1175                println!(
1176                    "   {} Failed to cleanup state cache: {}",
1177                    "[WARN]".yellow(),
1178                    e
1179                );
1180            }
1181        }
1182
1183        // Pass 6: Fix memento (point to a valid session)
1184        println!(
1185            "   {} Pass 6: Fixing session memento (last active session)...",
1186            "[*]".cyan()
1187        );
1188        // Pick the most recently active non-empty session as preferred
1189        let preferred_id = read_chat_session_index(&db_path).ok().and_then(|idx| {
1190            idx.entries
1191                .iter()
1192                .filter(|(_, e)| !e.is_empty)
1193                .max_by_key(|(_, e)| e.last_message_date)
1194                .map(|(id, _)| id.clone())
1195        });
1196        match fix_session_memento(&db_path, &valid_ids, preferred_id.as_deref()) {
1197            Ok(true) => {
1198                println!(
1199                    "   {} Memento updated to point to: {}",
1200                    "[OK]".green(),
1201                    preferred_id
1202                        .as_deref()
1203                        .unwrap_or("(first valid session)")
1204                        .cyan()
1205                );
1206            }
1207            Ok(false) => {
1208                println!(
1209                    "   {} Memento already points to a valid session",
1210                    "[OK]".green()
1211                );
1212            }
1213            Err(e) => {
1214                println!("   {} Failed to fix memento: {}", "[WARN]".yellow(), e);
1215            }
1216        }
1217    }
1218
1219    // Reopen VS Code if requested
1220    if reopen && vscode_was_running {
1221        println!("   {} Reopening VS Code...", "[*]".yellow());
1222        reopen_vscode(Some(&path_str))?;
1223        println!(
1224            "   {} VS Code launched. Sessions should now load correctly.",
1225            "[OK]".green()
1226        );
1227    } else if should_close && vscode_was_running {
1228        println!(
1229            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1230            "[!]".yellow()
1231        );
1232        println!("   Run: {}", format!("code {}", path.display()).cyan());
1233    }
1234
1235    Ok(())
1236}
1237
1238/// Recursively scan a directory tree for workspaces and repair all discovered sessions
1239fn register_repair_recursive(
1240    root_path: Option<&str>,
1241    max_depth: Option<usize>,
1242    exclude_patterns: &[String],
1243    dry_run: bool,
1244    force: bool,
1245    close_vscode: bool,
1246    reopen: bool,
1247) -> Result<()> {
1248    let root = resolve_path(root_path);
1249    let should_close = close_vscode || reopen;
1250
1251    println!(
1252        "{} Recursively scanning for workspaces to repair from: {}",
1253        "[CSM]".cyan().bold(),
1254        root.display()
1255    );
1256
1257    if dry_run {
1258        println!("{} Dry run mode — no changes will be made", "[!]".yellow());
1259    }
1260
1261    // Handle VS Code lifecycle
1262    let vscode_was_running = is_vscode_running();
1263    if vscode_was_running && !dry_run {
1264        if should_close {
1265            if !confirm_close_vscode(force) {
1266                println!("{} Aborted.", "[!]".yellow());
1267                return Ok(());
1268            }
1269            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1270            close_vscode_and_wait(30)?;
1271            println!("   {} VS Code closed.\n", "[OK]".green());
1272        } else if !force {
1273            println!(
1274                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1275                "[!]".yellow()
1276            );
1277            println!(
1278                "   Use {} to close VS Code first, or {} to force.",
1279                "--reopen".cyan(),
1280                "--force".cyan()
1281            );
1282            return Err(CsmError::VSCodeRunning.into());
1283        }
1284    }
1285
1286    // Get all VS Code workspaces
1287    let workspaces = discover_workspaces()?;
1288    println!(
1289        "   Found {} VS Code workspaces to check",
1290        workspaces.len().to_string().cyan()
1291    );
1292
1293    // Build a map of normalized project paths to workspace info
1294    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
1295        std::collections::HashMap::new();
1296    for ws in &workspaces {
1297        if let Some(ref project_path) = ws.project_path {
1298            let normalized = normalize_path(project_path);
1299            workspace_map.entry(normalized).or_default().push(ws);
1300        }
1301    }
1302
1303    // Compile exclude patterns
1304    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
1305        .iter()
1306        .filter_map(|p| glob::Pattern::new(p).ok())
1307        .collect();
1308
1309    let default_excludes = [
1310        "node_modules",
1311        ".git",
1312        "target",
1313        "build",
1314        "dist",
1315        ".venv",
1316        "venv",
1317        "__pycache__",
1318        ".cache",
1319        "vendor",
1320        ".cargo",
1321    ];
1322
1323    let mut total_dirs_scanned = 0usize;
1324    let mut workspaces_found = 0usize;
1325    let mut total_compacted = 0usize;
1326    let mut total_synced = 0usize;
1327    let mut total_issues_found = 0usize;
1328    let mut total_issues_fixed = 0usize;
1329    let mut repair_results: Vec<(String, usize, bool, String)> = Vec::new(); // (path, issues, success, detail)
1330
1331    // Walk the directory tree looking for known workspaces
1332    fn walk_for_repair(
1333        dir: &Path,
1334        root: &Path,
1335        current_depth: usize,
1336        max_depth: Option<usize>,
1337        workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
1338        exclude_matchers: &[glob::Pattern],
1339        default_excludes: &[&str],
1340        dry_run: bool,
1341        force: bool,
1342        total_dirs_scanned: &mut usize,
1343        workspaces_found: &mut usize,
1344        total_compacted: &mut usize,
1345        total_synced: &mut usize,
1346        total_issues_found: &mut usize,
1347        total_issues_fixed: &mut usize,
1348        repair_results: &mut Vec<(String, usize, bool, String)>,
1349    ) -> Result<()> {
1350        if let Some(max) = max_depth {
1351            if current_depth > max {
1352                return Ok(());
1353            }
1354        }
1355
1356        *total_dirs_scanned += 1;
1357
1358        // Check if this directory is a known workspace
1359        let normalized = normalize_path(&dir.to_string_lossy());
1360        if let Some(ws_list) = workspace_map.get(&normalized) {
1361            for ws in ws_list {
1362                if ws.has_chat_sessions && ws.chat_session_count > 0 {
1363                    *workspaces_found += 1;
1364
1365                    let display_name = ws.project_path.as_deref().unwrap_or(&ws.hash);
1366
1367                    // Diagnose first
1368                    let chat_dir = ws.workspace_path.join("chatSessions");
1369                    match crate::storage::diagnose_workspace_sessions(&ws.hash, &chat_dir) {
1370                        Ok(diag) => {
1371                            let issue_count = diag.issues.len();
1372                            *total_issues_found += issue_count;
1373
1374                            if issue_count == 0 {
1375                                println!(
1376                                    "   {} {} — {} sessions, healthy",
1377                                    "[OK]".green(),
1378                                    display_name.cyan(),
1379                                    ws.chat_session_count
1380                                );
1381                                repair_results.push((
1382                                    display_name.to_string(),
1383                                    0,
1384                                    true,
1385                                    "healthy".to_string(),
1386                                ));
1387                            } else {
1388                                let issue_kinds: Vec<String> = {
1389                                    let mut kinds: Vec<String> = Vec::new();
1390                                    for issue in &diag.issues {
1391                                        let s = format!("{}", issue.kind);
1392                                        if !kinds.contains(&s) {
1393                                            kinds.push(s);
1394                                        }
1395                                    }
1396                                    kinds
1397                                };
1398
1399                                println!(
1400                                    "   {} {} — {} sessions, {} issue(s): {}",
1401                                    "[!]".yellow(),
1402                                    display_name.cyan(),
1403                                    ws.chat_session_count,
1404                                    issue_count,
1405                                    issue_kinds.join(", ")
1406                                );
1407
1408                                if !dry_run {
1409                                    // Recover orphaned sessions from old workspace hashes first
1410                                    if let Some(ref project_path) = ws.project_path {
1411                                        match crate::workspace::recover_orphaned_sessions_from_old_hashes(project_path) {
1412                                            Ok(0) => {}
1413                                            Ok(n) => {
1414                                                println!(
1415                                                    "      {} Recovered {} orphaned session(s) from old hashes",
1416                                                    "[OK]".green(),
1417                                                    n
1418                                                );
1419                                            }
1420                                            Err(_) => {} // Non-fatal
1421                                        }
1422                                    }
1423
1424                                    match repair_workspace_sessions(
1425                                        &ws.hash,
1426                                        &chat_dir,
1427                                        force || true,
1428                                    ) {
1429                                        Ok((compacted, synced)) => {
1430                                            *total_compacted += compacted;
1431                                            *total_synced += synced;
1432                                            *total_issues_fixed += issue_count;
1433
1434                                            // Also handle stale .json cleanup
1435                                            let mut deleted_json = 0;
1436                                            let mut jsonl_sessions: HashSet<String> =
1437                                                HashSet::new();
1438                                            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1439                                                for entry in entries.flatten() {
1440                                                    let p = entry.path();
1441                                                    if p.extension().is_some_and(|e| e == "jsonl") {
1442                                                        if let Some(stem) = p.file_stem() {
1443                                                            jsonl_sessions.insert(
1444                                                                stem.to_string_lossy().to_string(),
1445                                                            );
1446                                                        }
1447                                                    }
1448                                                }
1449                                            }
1450                                            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1451                                                for entry in entries.flatten() {
1452                                                    let p = entry.path();
1453                                                    if p.extension().is_some_and(|e| e == "json") {
1454                                                        if let Some(stem) = p.file_stem() {
1455                                                            if jsonl_sessions.contains(
1456                                                                &stem.to_string_lossy().to_string(),
1457                                                            ) {
1458                                                                let bak =
1459                                                                    p.with_extension("json.bak");
1460                                                                let _ = std::fs::rename(&p, &bak);
1461                                                                deleted_json += 1;
1462                                                            }
1463                                                        }
1464                                                    }
1465                                                }
1466                                            }
1467                                            if deleted_json > 0 {
1468                                                let _ = repair_workspace_sessions(
1469                                                    &ws.hash, &chat_dir, true,
1470                                                );
1471                                            }
1472
1473                                            // Repair DB caches (model cache, state cache, memento, .json.bak recovery)
1474                                            let _ = repair_workspace_db_caches(
1475                                                &ws.hash, &chat_dir, false,
1476                                            );
1477
1478                                            let detail = format!(
1479                                                "{} compacted, {} synced{}",
1480                                                compacted,
1481                                                synced,
1482                                                if deleted_json > 0 {
1483                                                    format!(
1484                                                        ", {} stale .json backed up",
1485                                                        deleted_json
1486                                                    )
1487                                                } else {
1488                                                    String::new()
1489                                                }
1490                                            );
1491                                            println!("      {} Fixed: {}", "[OK]".green(), detail);
1492                                            repair_results.push((
1493                                                display_name.to_string(),
1494                                                issue_count,
1495                                                true,
1496                                                detail,
1497                                            ));
1498                                        }
1499                                        Err(e) => {
1500                                            println!("      {} Failed: {}", "[ERR]".red(), e);
1501                                            repair_results.push((
1502                                                display_name.to_string(),
1503                                                issue_count,
1504                                                false,
1505                                                e.to_string(),
1506                                            ));
1507                                        }
1508                                    }
1509                                } else {
1510                                    // Dry run: just list the issues
1511                                    for issue in &diag.issues {
1512                                        println!(
1513                                            "      {} {} — {}",
1514                                            "→".bright_black(),
1515                                            issue.session_id[..8.min(issue.session_id.len())]
1516                                                .to_string(),
1517                                            issue.kind
1518                                        );
1519                                    }
1520                                    repair_results.push((
1521                                        display_name.to_string(),
1522                                        issue_count,
1523                                        true,
1524                                        "dry run".to_string(),
1525                                    ));
1526                                }
1527                            }
1528                        }
1529                        Err(e) => {
1530                            println!("   {} {} — scan failed: {}", "[ERR]".red(), display_name, e);
1531                        }
1532                    }
1533                }
1534            }
1535        }
1536
1537        // Recurse into subdirectories
1538        let entries = match std::fs::read_dir(dir) {
1539            Ok(e) => e,
1540            Err(_) => return Ok(()),
1541        };
1542
1543        for entry in entries {
1544            let entry = match entry {
1545                Ok(e) => e,
1546                Err(_) => continue,
1547            };
1548            let path = entry.path();
1549            if !path.is_dir() {
1550                continue;
1551            }
1552
1553            let dir_name = entry.file_name().to_string_lossy().to_string();
1554
1555            // Skip hidden directories
1556            if dir_name.starts_with('.') {
1557                continue;
1558            }
1559
1560            // Skip default excludes
1561            if default_excludes.iter().any(|e| dir_name == *e) {
1562                continue;
1563            }
1564
1565            // Skip user-specified excludes
1566            if exclude_matchers.iter().any(|p| p.matches(&dir_name)) {
1567                continue;
1568            }
1569
1570            walk_for_repair(
1571                &path,
1572                root,
1573                current_depth + 1,
1574                max_depth,
1575                workspace_map,
1576                exclude_matchers,
1577                default_excludes,
1578                dry_run,
1579                force,
1580                total_dirs_scanned,
1581                workspaces_found,
1582                total_compacted,
1583                total_synced,
1584                total_issues_found,
1585                total_issues_fixed,
1586                repair_results,
1587            )?;
1588        }
1589
1590        Ok(())
1591    }
1592
1593    walk_for_repair(
1594        &root,
1595        &root,
1596        0,
1597        max_depth,
1598        &workspace_map,
1599        &exclude_matchers,
1600        &default_excludes,
1601        dry_run,
1602        force,
1603        &mut total_dirs_scanned,
1604        &mut workspaces_found,
1605        &mut total_compacted,
1606        &mut total_synced,
1607        &mut total_issues_found,
1608        &mut total_issues_fixed,
1609        &mut repair_results,
1610    )?;
1611
1612    // Print summary
1613    println!("\n{}", "═".repeat(60).cyan());
1614    println!("{} Recursive repair scan complete", "[OK]".green().bold());
1615    println!("{}", "═".repeat(60).cyan());
1616    println!(
1617        "   Directories scanned:    {}",
1618        total_dirs_scanned.to_string().cyan()
1619    );
1620    println!(
1621        "   Workspaces found:       {}",
1622        workspaces_found.to_string().cyan()
1623    );
1624    println!(
1625        "   Issues detected:        {}",
1626        if total_issues_found > 0 {
1627            total_issues_found.to_string().yellow()
1628        } else {
1629            total_issues_found.to_string().green()
1630        }
1631    );
1632    if !dry_run {
1633        println!(
1634            "   Issues fixed:           {}",
1635            total_issues_fixed.to_string().green()
1636        );
1637        println!(
1638            "   Files compacted:        {}",
1639            total_compacted.to_string().cyan()
1640        );
1641        println!(
1642            "   Index entries synced:   {}",
1643            total_synced.to_string().cyan()
1644        );
1645    }
1646
1647    let failed_count = repair_results.iter().filter(|(_, _, ok, _)| !ok).count();
1648    if failed_count > 0 {
1649        println!(
1650            "\n   {} {} workspace(s) had repair errors",
1651            "[!]".yellow(),
1652            failed_count.to_string().red()
1653        );
1654    }
1655
1656    // Reopen VS Code if requested
1657    if reopen && vscode_was_running {
1658        println!("   {} Reopening VS Code...", "[*]".yellow());
1659        reopen_vscode(None)?;
1660        println!(
1661            "   {} VS Code launched. Sessions should now load correctly.",
1662            "[OK]".green()
1663        );
1664    } else if should_close && vscode_was_running {
1665        println!(
1666            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1667            "[!]".yellow()
1668        );
1669    }
1670
1671    Ok(())
1672}
1673
1674/// Repair the VS Code DB caches for a single workspace:
1675/// - `.json.bak` recovery (restore truncated sessions from backups)
1676/// - `agentSessions.model.cache` rebuild (makes sessions visible in Chat sidebar)
1677/// - `agentSessions.state.cache` cleanup (removes stale entries for deleted sessions)
1678/// - `memento/interactive-session-view-copilot` fix (points to a valid session)
1679///
1680/// Call this AFTER `repair_workspace_sessions()` and stale .json cleanup.
1681fn repair_workspace_db_caches(
1682    workspace_id: &str,
1683    chat_sessions_dir: &Path,
1684    verbose: bool,
1685) -> Result<()> {
1686    let db_path = get_workspace_storage_db(workspace_id)?;
1687    if !db_path.exists() {
1688        return Ok(());
1689    }
1690
1691    // .json.bak recovery
1692    match recover_from_json_bak(chat_sessions_dir) {
1693        Ok(0) => {}
1694        Ok(n) => {
1695            if verbose {
1696                println!(
1697                    "      {} Recovered {} session(s) from .json.bak",
1698                    "[OK]".green(),
1699                    n
1700                );
1701            }
1702        }
1703        Err(e) => {
1704            if verbose {
1705                println!(
1706                    "      {} .json.bak recovery failed: {}",
1707                    "[WARN]".yellow(),
1708                    e
1709                );
1710            }
1711        }
1712    }
1713
1714    // Rebuild model cache
1715    match read_chat_session_index(&db_path) {
1716        Ok(index) => {
1717            if let Ok(n) = rebuild_model_cache(&db_path, &index) {
1718                if verbose && n > 0 {
1719                    println!(
1720                        "      {} Model cache rebuilt ({} entries)",
1721                        "[OK]".green(),
1722                        n
1723                    );
1724                }
1725            }
1726        }
1727        Err(_) => {}
1728    }
1729
1730    // Collect valid session IDs from disk
1731    let mut valid_ids: HashSet<String> = HashSet::new();
1732    if chat_sessions_dir.exists() {
1733        if let Ok(entries) = std::fs::read_dir(chat_sessions_dir) {
1734            for entry in entries.flatten() {
1735                let p = entry.path();
1736                if p.extension().is_some_and(|e| e == "jsonl") {
1737                    if let Some(stem) = p.file_stem() {
1738                        valid_ids.insert(stem.to_string_lossy().to_string());
1739                    }
1740                }
1741            }
1742        }
1743    }
1744
1745    // Cleanup state cache
1746    match cleanup_state_cache(&db_path, &valid_ids) {
1747        Ok(n) if n > 0 && verbose => {
1748            println!(
1749                "      {} State cache: removed {} stale entries",
1750                "[OK]".green(),
1751                n
1752            );
1753        }
1754        _ => {}
1755    }
1756
1757    // Fix memento
1758    let preferred_id = read_chat_session_index(&db_path).ok().and_then(|idx| {
1759        idx.entries
1760            .iter()
1761            .filter(|(_, e)| !e.is_empty)
1762            .max_by_key(|(_, e)| e.last_message_date)
1763            .map(|(id, _)| id.clone())
1764    });
1765    match fix_session_memento(&db_path, &valid_ids, preferred_id.as_deref()) {
1766        Ok(true) if verbose => {
1767            println!(
1768                "      {} Memento updated to: {}",
1769                "[OK]".green(),
1770                preferred_id.as_deref().unwrap_or("(first valid)"),
1771            );
1772        }
1773        _ => {}
1774    }
1775
1776    Ok(())
1777}
1778
1779/// Repair all workspaces that have chat sessions
1780fn register_repair_all(force: bool, close_vscode: bool, reopen: bool) -> Result<()> {
1781    let should_close = close_vscode || reopen;
1782
1783    println!(
1784        "{} Repairing all workspaces with chat sessions...\n",
1785        "[CSM]".cyan().bold(),
1786    );
1787
1788    // Handle VS Code lifecycle once for all workspaces
1789    let vscode_was_running = is_vscode_running();
1790    if vscode_was_running {
1791        if should_close {
1792            if !confirm_close_vscode(force) {
1793                println!("{} Aborted.", "[!]".yellow());
1794                return Ok(());
1795            }
1796            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1797            close_vscode_and_wait(30)?;
1798            println!("   {} VS Code closed.\n", "[OK]".green());
1799        } else if !force {
1800            println!(
1801                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1802                "[!]".yellow()
1803            );
1804            println!(
1805                "   Use {} to close VS Code first, or {} to force.",
1806                "--reopen".cyan(),
1807                "--force".cyan()
1808            );
1809            return Err(CsmError::VSCodeRunning.into());
1810        }
1811    }
1812
1813    let workspaces = discover_workspaces()?;
1814    let ws_with_sessions: Vec<_> = workspaces
1815        .iter()
1816        .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
1817        .collect();
1818
1819    if ws_with_sessions.is_empty() {
1820        println!("{} No workspaces with chat sessions found.", "[!]".yellow());
1821        return Ok(());
1822    }
1823
1824    println!(
1825        "   Found {} workspaces with chat sessions\n",
1826        ws_with_sessions.len().to_string().cyan()
1827    );
1828
1829    let mut total_compacted = 0usize;
1830    let mut total_synced = 0usize;
1831    let mut succeeded = 0usize;
1832    let mut failed = 0usize;
1833
1834    for (i, ws) in ws_with_sessions.iter().enumerate() {
1835        let display_name = ws.project_path.as_deref().unwrap_or(&ws.hash);
1836        println!(
1837            "[{}/{}] {} {}",
1838            i + 1,
1839            ws_with_sessions.len(),
1840            "===".dimmed(),
1841            display_name.cyan()
1842        );
1843
1844        let chat_sessions_dir = ws.workspace_path.join("chatSessions");
1845        if !chat_sessions_dir.exists() {
1846            println!(
1847                "   {} No chatSessions directory, skipping.\n",
1848                "[!]".yellow()
1849            );
1850            continue;
1851        }
1852
1853        match repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true) {
1854            Ok((compacted, index_fixed)) => {
1855                // Delete stale .json files when a .jsonl exists for the same session
1856                let mut deleted_json = 0;
1857                let mut jsonl_sessions: HashSet<String> = HashSet::new();
1858                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1859                    let entry = entry?;
1860                    let p = entry.path();
1861                    if p.extension().is_some_and(|e| e == "jsonl") {
1862                        if let Some(stem) = p.file_stem() {
1863                            jsonl_sessions.insert(stem.to_string_lossy().to_string());
1864                        }
1865                    }
1866                }
1867                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1868                    let entry = entry?;
1869                    let p = entry.path();
1870                    if p.extension().is_some_and(|e| e == "json") {
1871                        if let Some(stem) = p.file_stem() {
1872                            if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1873                                let bak = p.with_extension("json.bak");
1874                                std::fs::rename(&p, &bak)?;
1875                                deleted_json += 1;
1876                            }
1877                        }
1878                    }
1879                }
1880                if deleted_json > 0 {
1881                    repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true)?;
1882                }
1883
1884                // Repair DB caches (model cache, state cache, memento, .json.bak recovery)
1885                let _ = repair_workspace_db_caches(&ws.hash, &chat_sessions_dir, false);
1886
1887                total_compacted += compacted;
1888                total_synced += index_fixed;
1889                succeeded += 1;
1890                println!(
1891                    "   {} {} compacted, {} synced{}\n",
1892                    "[OK]".green(),
1893                    compacted,
1894                    index_fixed,
1895                    if deleted_json > 0 {
1896                        format!(", {} stale .json backed up", deleted_json)
1897                    } else {
1898                        String::new()
1899                    }
1900                );
1901            }
1902            Err(e) => {
1903                failed += 1;
1904                println!("   {} {}\n", "[ERR]".red(), e);
1905            }
1906        }
1907    }
1908
1909    println!(
1910        "{} Repair complete: {}/{} workspaces, {} compacted, {} index entries synced",
1911        "[OK]".green().bold(),
1912        succeeded.to_string().green(),
1913        ws_with_sessions.len(),
1914        total_compacted.to_string().cyan(),
1915        total_synced.to_string().cyan()
1916    );
1917    if failed > 0 {
1918        println!(
1919            "   {} {} workspace(s) had errors",
1920            "[!]".yellow(),
1921            failed.to_string().red()
1922        );
1923    }
1924
1925    // Reopen VS Code if requested
1926    if reopen && vscode_was_running {
1927        println!("   {} Reopening VS Code...", "[*]".yellow());
1928        reopen_vscode(None)?;
1929        println!(
1930            "   {} VS Code launched. Sessions should now load correctly.",
1931            "[OK]".green()
1932        );
1933    } else if should_close && vscode_was_running {
1934        println!(
1935            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1936            "[!]".yellow()
1937        );
1938    }
1939
1940    Ok(())
1941}
1942
1943/// Trim oversized sessions by keeping only the most recent requests.
1944///
1945/// Very long chat sessions (100+ requests) can grow to 50-100+ MB, which causes
1946/// VS Code to fail loading them. This command trims the requests array to keep
1947/// only the most recent N entries, dramatically reducing file size. The full
1948/// session is preserved as a `.jsonl.bak` backup.
1949pub fn register_trim(
1950    project_path: Option<&str>,
1951    keep: usize,
1952    session_id: Option<&str>,
1953    all: bool,
1954    threshold_mb: u64,
1955    force: bool,
1956) -> Result<()> {
1957    let path = resolve_path(project_path);
1958
1959    println!(
1960        "{} Trimming oversized sessions for: {}",
1961        "[CSM]".cyan().bold(),
1962        path.display()
1963    );
1964
1965    // Find the workspace
1966    let path_str = path.to_string_lossy().to_string();
1967    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
1968        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
1969
1970    let chat_sessions_dir = ws_path.join("chatSessions");
1971
1972    if !chat_sessions_dir.exists() {
1973        println!(
1974            "{} No chatSessions directory found at: {}",
1975            "[!]".yellow(),
1976            chat_sessions_dir.display()
1977        );
1978        return Ok(());
1979    }
1980
1981    // Check VS Code
1982    if !force && is_vscode_running() {
1983        println!(
1984            "{} VS Code is running. Use {} to force.",
1985            "[!]".yellow(),
1986            "--force".cyan()
1987        );
1988        return Err(CsmError::VSCodeRunning.into());
1989    }
1990
1991    let mut trimmed_count = 0;
1992
1993    if let Some(sid) = session_id {
1994        // Trim a specific session
1995        let jsonl_path = chat_sessions_dir.join(format!("{}.jsonl", sid));
1996        if !jsonl_path.exists() {
1997            return Err(
1998                CsmError::InvalidSessionFormat(format!("Session not found: {}", sid)).into(),
1999            );
2000        }
2001
2002        let size_mb = std::fs::metadata(&jsonl_path)?.len() / (1024 * 1024);
2003        println!(
2004            "   {} Trimming {} ({}MB, keeping last {} requests)...",
2005            "[*]".cyan(),
2006            sid,
2007            size_mb,
2008            keep
2009        );
2010
2011        match trim_session_jsonl(&jsonl_path, keep) {
2012            Ok((orig, kept, orig_mb, new_mb)) => {
2013                println!(
2014                    "   {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
2015                    "[OK]".green(),
2016                    orig,
2017                    kept,
2018                    orig_mb,
2019                    new_mb
2020                );
2021                trimmed_count += 1;
2022            }
2023            Err(e) => {
2024                println!("   {} Failed to trim {}: {}", "[ERR]".red(), sid, e);
2025            }
2026        }
2027    } else if all {
2028        // Trim all sessions over the threshold
2029        for entry in std::fs::read_dir(&chat_sessions_dir)? {
2030            let entry = entry?;
2031            let p = entry.path();
2032            if p.extension().is_some_and(|e| e == "jsonl") {
2033                let size = std::fs::metadata(&p)?.len();
2034                let size_mb_val = size / (1024 * 1024);
2035
2036                if size_mb_val >= threshold_mb {
2037                    let stem = p
2038                        .file_stem()
2039                        .map(|s| s.to_string_lossy().to_string())
2040                        .unwrap_or_default();
2041                    println!(
2042                        "   {} Trimming {} ({}MB, keeping last {} requests)...",
2043                        "[*]".cyan(),
2044                        stem,
2045                        size_mb_val,
2046                        keep
2047                    );
2048
2049                    match trim_session_jsonl(&p, keep) {
2050                        Ok((orig, kept, orig_mb, new_mb)) => {
2051                            println!(
2052                                "   {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
2053                                "[OK]".green(),
2054                                orig,
2055                                kept,
2056                                orig_mb,
2057                                new_mb
2058                            );
2059                            trimmed_count += 1;
2060                        }
2061                        Err(e) => {
2062                            println!("   {} Failed to trim {}: {}", "[WARN]".yellow(), stem, e);
2063                        }
2064                    }
2065                }
2066            }
2067        }
2068    } else {
2069        // Auto-detect: find the largest session over the threshold
2070        let mut largest: Option<(PathBuf, u64)> = None;
2071
2072        for entry in std::fs::read_dir(&chat_sessions_dir)? {
2073            let entry = entry?;
2074            let p = entry.path();
2075            if p.extension().is_some_and(|e| e == "jsonl") {
2076                let size = std::fs::metadata(&p)?.len();
2077                let size_mb_val = size / (1024 * 1024);
2078
2079                if size_mb_val >= threshold_mb {
2080                    if largest.as_ref().map_or(true, |(_, s)| size > *s) {
2081                        largest = Some((p, size));
2082                    }
2083                }
2084            }
2085        }
2086
2087        match largest {
2088            Some((p, size)) => {
2089                let stem = p
2090                    .file_stem()
2091                    .map(|s| s.to_string_lossy().to_string())
2092                    .unwrap_or_default();
2093                let size_mb_val = size / (1024 * 1024);
2094                println!(
2095                    "   {} Trimming largest session: {} ({}MB, keeping last {} requests)...",
2096                    "[*]".cyan(),
2097                    stem,
2098                    size_mb_val,
2099                    keep
2100                );
2101
2102                match trim_session_jsonl(&p, keep) {
2103                    Ok((orig, kept, orig_mb, new_mb)) => {
2104                        println!(
2105                            "   {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
2106                            "[OK]".green(),
2107                            orig,
2108                            kept,
2109                            orig_mb,
2110                            new_mb
2111                        );
2112                        trimmed_count += 1;
2113                    }
2114                    Err(e) => {
2115                        println!("   {} Failed to trim: {}", "[ERR]".red(), e);
2116                    }
2117                }
2118            }
2119            None => {
2120                println!(
2121                    "   {} No sessions found over {}MB threshold. Use {} to lower the threshold.",
2122                    "[*]".cyan(),
2123                    threshold_mb,
2124                    "--threshold-mb".cyan()
2125                );
2126            }
2127        }
2128    }
2129
2130    if trimmed_count > 0 {
2131        // Re-sync the index
2132        let _ = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true);
2133        println!(
2134            "\n{} Trim complete: {} session(s) trimmed. Full history backed up as .jsonl.bak",
2135            "[OK]".green().bold(),
2136            trimmed_count.to_string().cyan()
2137        );
2138    }
2139
2140    Ok(())
2141}