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    write_only: bool,
66) -> Result<()> {
67    let path = resolve_path(project_path);
68    // --reopen implies --close-vscode
69    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        // Use the existing merge functionality
79        let path_str = path.to_string_lossy().to_string();
80        return crate::commands::history_merge(
81            Some(&path_str),
82            None,  // title
83            force, // force
84            false, // no_backup
85        );
86    }
87
88    println!(
89        "{} Registering all sessions for: {}",
90        "[CSM]".cyan().bold(),
91        path.display()
92    );
93
94    // Find the workspace
95    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    // Detect if running from VS Code's integrated terminal
111    let in_vscode_terminal = std::env::var("TERM_PROGRAM")
112        .map(|v| v.to_lowercase().contains("vscode"))
113        .unwrap_or(false);
114
115    // Handle VS Code lifecycle
116    let vscode_was_running = is_vscode_running();
117    let mut will_reopen = reopen;
118    if vscode_was_running {
119        if write_only {
120            // Explicit --write-only: write to DB directly, spawn watchdog to
121            // re-apply after VS Code exits (since VS Code's in-memory cache
122            // will overwrite on shutdown)
123            println!(
124                "   {} VS Code is running. Writing to index with shutdown watchdog.",
125                "[*]".yellow()
126            );
127        } else if should_close {
128            // Explicit --close-vscode or --reopen
129            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            // --force from external terminal: close and reopen automatically
155            if in_vscode_terminal {
156                // Can't close VS Code from inside it; fall back to write-only + watchdog
157                println!(
158                    "   {} VS Code is running (in its terminal). Writing with shutdown watchdog.",
159                    "[*]".yellow()
160                );
161            } else {
162                // External terminal: --force means "just make it work"
163                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            // No flags: show helpful guidance
170            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    // Count sessions on disk
194    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    // Register all sessions (rebuild index from disk)
201    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    // Rebuild model cache (makes sessions visible in Chat sidebar)
210    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    // Cleanup state cache
238    {
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    // If VS Code was running and we didn't close it (write-only or --force from vscode terminal),
263    // spawn a watchdog to re-apply the index after VS Code exits
264    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    // Reopen VS Code if requested (or if --force from external terminal closed it)
302    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
320/// Spawn a detached watchdog process that waits for VS Code to exit, then re-applies
321/// the session index to state.vscdb. This ensures registrations survive VS Code's
322/// shutdown flush of its in-memory IStorageService cache.
323fn spawn_registration_watchdog(
324    ws_id: &str,
325    chat_sessions_dir: &Path,
326    _project_path: Option<&str>,
327) -> Result<()> {
328    // Write pending registration info to a temp file
329    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    // Spawn ourselves with the hidden `internal apply-pending` command
337    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
367/// Apply a pending registration after VS Code has exited.
368/// Called by the detached watchdog process (via `chasm internal apply-pending`).
369pub 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    // Wait for VS Code to exit (poll every 2 seconds, timeout after 10 minutes)
383    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            // Timed out waiting — clean up and exit
387            let _ = std::fs::remove_file(pending_file);
388            return Ok(());
389        }
390        std::thread::sleep(std::time::Duration::from_secs(2));
391    }
392
393    // Extra wait for file locks to release
394    std::thread::sleep(std::time::Duration::from_secs(2));
395
396    // Re-apply the registration
397    if chat_sessions_dir.exists() {
398        let _ = register_all_sessions_from_directory(ws_id, &chat_sessions_dir, true);
399    }
400
401    // Clean up the pending file
402    let _ = std::fs::remove_file(pending_file);
403
404    Ok(())
405}
406
407/// Register specific sessions by ID or title
408pub 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    // Find the workspace
417    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    // Check if VS Code is running
424    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    // Get the database path
434    let db_path = get_workspace_storage_db(&ws_id)?;
435
436    let mut registered_count = 0;
437
438    if let Some(titles) = titles {
439        // Register by title
440        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        // Register by ID (default)
482        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
550/// List sessions that exist on disk but are not in VS Code's index
551pub 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    // Find the workspace
561    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    // Get sessions currently in the index
573    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    // Find sessions on disk
583    let mut orphaned_sessions = Vec::new();
584
585    // Collect files, preferring .jsonl over .json for the same session ID
586    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
658/// Count session files in a directory (counts unique session IDs, preferring .jsonl)
659fn 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
677/// Find a session file by ID (supports partial matches, prefers .jsonl over .json)
678fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
679    // First try exact match (.jsonl preferred)
680    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    // Try partial match (prefix), preferring .jsonl
690    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            // Also check session_id inside the file
717            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
733/// Find sessions by title (case-insensitive partial match)
734fn 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    // Collect files, preferring .jsonl over .json for the same session ID
742    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
785/// Recursively walk directories and register orphaned sessions for all workspaces found.
786///
787/// Instead of walking the entire filesystem tree (which can be extremely slow for
788/// large directory hierarchies), this function discovers all VS Code workspaces upfront
789/// and filters them by the root path prefix. This is O(workspaces) instead of
790/// O(filesystem entries), making it orders of magnitude faster for deep trees.
791pub 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    // Check if VS Code is running
812    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    // Get all VS Code workspaces — this is fast (reads workspaceStorage metadata)
823    let workspaces = discover_workspaces()?;
824
825    // Compile exclude patterns
826    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
827        .iter()
828        .filter_map(|p| glob::Pattern::new(p).ok())
829        .collect();
830
831    // Default exclusions for common non-project directories
832    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    // Filter workspaces to those under the root path, applying exclusions and depth limits.
847    // This replaces the slow recursive filesystem walk with a fast filter over the
848    // already-known workspace list.
849    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            // Must be under the root path
857            let ws_normalized = normalize_path(project_path);
858            if !ws_normalized.starts_with(&root_normalized) {
859                return false;
860            }
861
862            // Check depth limit: count path components after root
863            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            // Check exclude patterns against the relative path and directory name
877            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            // Skip default excluded directory names (check each path component)
886            for component in relative.split(['/', '\\']) {
887                if default_excludes.contains(&component) {
888                    return false;
889                }
890            }
891
892            // Skip user exclude patterns
893            for pattern in &exclude_matchers {
894                if pattern.matches(relative) || pattern.matches(&dir_name) {
895                    return false;
896                }
897            }
898
899            // Must have chat sessions
900            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        // Progress indicator
920        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        // Count orphaned sessions
929        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                        // Register the sessions
950                        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    // Print summary
993    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
1043/// Count orphaned sessions in a workspace (on disk but not in index)
1044fn count_orphaned_sessions(
1045    workspace_id: &str,
1046    chat_sessions_dir: &Path,
1047) -> Result<(usize, usize, usize)> {
1048    // Get sessions in index
1049    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    // Count unique sessions on disk (preferring .jsonl over .json)
1054    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
1080/// Repair sessions: compact JSONL files and rebuild the index with correct metadata
1081pub 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    // Find the workspace
1118    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    // Handle VS Code lifecycle
1134    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    // Pass 0: Recover orphaned sessions from old workspace hashes
1159    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    // Run the repair
1182    // Pass 0.5: Recover from .json.bak when .jsonl has fewer requests (truncated migration)
1183    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    // Delete stale .json files when a .jsonl exists for the same session
1225    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                        // Rename to .json.bak to preserve as backup
1244                        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            // Re-sync index after removing .json files
1259            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    // Pass 4: Rebuild agentSessions.model.cache (makes sessions visible in Chat sidebar)
1269    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    // Pass 5: Cleanup agentSessions.state.cache (remove stale entries)
1301    println!(
1302        "   {} Pass 5: Cleaning up state cache (agentSessions.state.cache)...",
1303        "[*]".cyan()
1304    );
1305    {
1306        // Collect valid session IDs from disk
1307        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        // Pass 6: Fix memento (point to a valid session)
1340        println!(
1341            "   {} Pass 6: Fixing session memento (last active session)...",
1342            "[*]".cyan()
1343        );
1344        // Pick the most recently active non-empty session as preferred
1345        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    // Reopen VS Code if requested
1376    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
1394/// Recursively scan a directory tree for workspaces and repair all discovered sessions
1395fn 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    // Handle VS Code lifecycle
1418    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    // Get all VS Code workspaces
1443    let workspaces = discover_workspaces()?;
1444    println!(
1445        "   Found {} VS Code workspaces to check",
1446        workspaces.len().to_string().cyan()
1447    );
1448
1449    // Build a map of normalized project paths to workspace info
1450    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    // Compile exclude patterns
1460    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(); // (path, issues, success, detail)
1486
1487    // Walk the directory tree looking for known workspaces
1488    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        // Check if this directory is a known workspace
1515        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                    // Diagnose first
1524                    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                                    // Recover orphaned sessions from old workspace hashes first
1566                                    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(_) => {} // Non-fatal
1577                                        }
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                                            // Also handle stale .json cleanup
1591                                            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                                            // Repair DB caches (model cache, state cache, memento, .json.bak recovery)
1630                                            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                                    // Dry run: just list the issues
1667                                    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        // Recurse into subdirectories
1694        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            // Skip hidden directories
1712            if dir_name.starts_with('.') {
1713                continue;
1714            }
1715
1716            // Skip default excludes
1717            if default_excludes.iter().any(|e| dir_name == *e) {
1718                continue;
1719            }
1720
1721            // Skip user-specified excludes
1722            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    // Print summary
1769    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    // Reopen VS Code if requested
1813    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
1830/// Repair the VS Code DB caches for a single workspace:
1831/// - `.json.bak` recovery (restore truncated sessions from backups)
1832/// - `agentSessions.model.cache` rebuild (makes sessions visible in Chat sidebar)
1833/// - `agentSessions.state.cache` cleanup (removes stale entries for deleted sessions)
1834/// - `memento/interactive-session-view-copilot` fix (points to a valid session)
1835///
1836/// Call this AFTER `repair_workspace_sessions()` and stale .json cleanup.
1837fn 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    // .json.bak recovery
1848    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    // Rebuild model cache
1871    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    // Collect valid session IDs from disk
1887    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    // Cleanup state cache
1902    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    // Fix memento
1914    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
1935/// Repair all workspaces that have chat sessions
1936fn 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    // Handle VS Code lifecycle once for all workspaces
1945    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                // Delete stale .json files when a .jsonl exists for the same session
2012                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                // Repair DB caches (model cache, state cache, memento, .json.bak recovery)
2041                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    // Reopen VS Code if requested
2082    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
2099/// Trim oversized sessions by keeping only the most recent requests.
2100///
2101/// Very long chat sessions (100+ requests) can grow to 50-100+ MB, which causes
2102/// VS Code to fail loading them. This command trims the requests array to keep
2103/// only the most recent N entries, dramatically reducing file size. The full
2104/// session is preserved as a `.jsonl.bak` backup.
2105pub 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    // Find the workspace
2122    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    // Check VS Code
2138    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        // Trim a specific session
2151        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        // Trim all sessions over the threshold
2185        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        // Auto-detect: find the largest session over the threshold
2226        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        // Re-sync the index
2288        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}