memvid_cli/commands/
session.rs

1//! CLI commands for time-travel replay session management.
2//!
3//! These commands enable recording and managing agent sessions for
4//! debugging and testing purposes.
5
6use anyhow::Result;
7use clap::{Args, Subcommand};
8use colored::Colorize;
9use memvid_core::Memvid;
10
11/// Session management commands for time-travel replay
12#[derive(Args, Debug)]
13pub struct SessionArgs {
14    #[command(subcommand)]
15    pub command: SessionCommand,
16}
17
18#[derive(Subcommand, Debug)]
19pub enum SessionCommand {
20    /// Start recording a new session
21    Start(SessionStartArgs),
22    /// End the current recording session
23    End(SessionEndArgs),
24    /// List all recorded sessions
25    List(SessionListArgs),
26    /// View details of a specific session
27    View(SessionViewArgs),
28    /// Create a checkpoint in the current session
29    Checkpoint(SessionCheckpointArgs),
30    /// Delete a recorded session
31    Delete(SessionDeleteArgs),
32    /// Save sessions to the memory file
33    Save(SessionSaveArgs),
34    /// Load sessions from the memory file
35    Load(SessionLoadArgs),
36    /// Replay a recorded session and verify consistency
37    Replay(SessionReplayArgs),
38    /// Compare two recorded sessions
39    Compare(SessionCompareArgs),
40}
41
42#[derive(Args, Debug)]
43pub struct SessionStartArgs {
44    /// Path to the memory file
45    pub file: String,
46
47    /// Optional name for the session
48    #[arg(short, long)]
49    pub name: Option<String>,
50
51    /// Auto-checkpoint interval (number of actions between checkpoints, 0 = disabled)
52    #[arg(long, default_value = "0")]
53    pub auto_checkpoint: u64,
54}
55
56#[derive(Args, Debug)]
57pub struct SessionEndArgs {
58    /// Path to the memory file
59    pub file: String,
60}
61
62#[derive(Args, Debug)]
63pub struct SessionListArgs {
64    /// Path to the memory file
65    pub file: String,
66
67    /// Output format (text, json)
68    #[arg(short, long, default_value = "text")]
69    pub format: String,
70}
71
72#[derive(Args, Debug)]
73pub struct SessionViewArgs {
74    /// Path to the memory file
75    pub file: String,
76
77    /// Session ID to view
78    #[arg(short, long)]
79    pub session: String,
80
81    /// Output format (text, json)
82    #[arg(short, long, default_value = "text")]
83    pub format: String,
84}
85
86#[derive(Args, Debug)]
87pub struct SessionCheckpointArgs {
88    /// Path to the memory file
89    pub file: String,
90}
91
92#[derive(Args, Debug)]
93pub struct SessionDeleteArgs {
94    /// Path to the memory file
95    pub file: String,
96
97    /// Session ID to delete
98    #[arg(short, long)]
99    pub session: String,
100}
101
102#[derive(Args, Debug)]
103pub struct SessionSaveArgs {
104    /// Path to the memory file
105    pub file: String,
106}
107
108#[derive(Args, Debug)]
109pub struct SessionLoadArgs {
110    /// Path to the memory file
111    pub file: String,
112}
113
114#[derive(Args, Debug)]
115pub struct SessionReplayArgs {
116    /// Path to the memory file
117    pub file: String,
118
119    /// Session ID to replay
120    #[arg(short, long)]
121    pub session: String,
122
123    /// Start replay from a specific checkpoint
124    #[arg(long)]
125    pub from_checkpoint: Option<u64>,
126
127    /// Number of results to retrieve during replay (default: same as original)
128    /// Use higher values to discover documents that were missed due to low top-k
129    #[arg(short = 'k', long)]
130    pub top_k: Option<usize>,
131
132    /// Use adaptive retrieval that adjusts top-k based on score distribution
133    #[arg(long)]
134    pub adaptive: bool,
135
136    /// Minimum relevancy score for adaptive retrieval (0.0-1.0)
137    #[arg(long, default_value = "0.5")]
138    pub min_relevancy: f32,
139
140    /// Skip put/update/delete actions
141    #[arg(long)]
142    pub skip_mutations: bool,
143
144    /// Skip find/search actions
145    #[arg(long)]
146    pub skip_finds: bool,
147
148    /// Skip ask actions (LLM calls)
149    #[arg(long)]
150    pub skip_asks: bool,
151
152    /// Stop on first mismatch
153    #[arg(long)]
154    pub stop_on_mismatch: bool,
155
156    /// Output format (text, json)
157    #[arg(short, long, default_value = "text")]
158    pub format: String,
159
160    /// Launch the Time Machine web UI for visual replay
161    #[cfg(feature = "web")]
162    #[arg(long)]
163    pub web: bool,
164
165    /// Port for the Time Machine web server (default: 4242)
166    #[cfg(feature = "web")]
167    #[arg(long, default_value = "4242")]
168    pub port: u16,
169
170    /// Don't auto-open browser when starting web UI
171    #[cfg(feature = "web")]
172    #[arg(long)]
173    pub no_open: bool,
174}
175
176#[derive(Args, Debug)]
177pub struct SessionCompareArgs {
178    /// Path to the memory file
179    pub file: String,
180
181    /// First session ID
182    #[arg(short = 'a', long)]
183    pub session_a: String,
184
185    /// Second session ID
186    #[arg(short = 'b', long)]
187    pub session_b: String,
188
189    /// Output format (text, json)
190    #[arg(short, long, default_value = "text")]
191    pub format: String,
192}
193
194/// Handle session subcommands
195#[cfg(feature = "replay")]
196pub fn handle_session(args: SessionArgs) -> Result<()> {
197    match args.command {
198        SessionCommand::Start(args) => handle_session_start(args),
199        SessionCommand::End(args) => handle_session_end(args),
200        SessionCommand::List(args) => handle_session_list(args),
201        SessionCommand::View(args) => handle_session_view(args),
202        SessionCommand::Checkpoint(args) => handle_session_checkpoint(args),
203        SessionCommand::Delete(args) => handle_session_delete(args),
204        SessionCommand::Save(args) => handle_session_save(args),
205        SessionCommand::Load(args) => handle_session_load(args),
206        SessionCommand::Replay(args) => handle_session_replay(args),
207        SessionCommand::Compare(args) => handle_session_compare(args),
208    }
209}
210
211#[cfg(not(feature = "replay"))]
212pub fn handle_session(_args: SessionArgs) -> Result<()> {
213    bail!("Session recording requires the 'replay' feature. Rebuild with --features replay")
214}
215
216#[cfg(feature = "replay")]
217fn handle_session_start(args: SessionStartArgs) -> Result<()> {
218    use memvid_core::replay::ReplayConfig;
219
220    let mut mem = Memvid::open(&args.file)?;
221
222    // Check if there's already an active session
223    if mem.load_active_session()? {
224        let session_id = mem.active_session_id().unwrap();
225        anyhow::bail!(
226            "Session {} is already active. End it first with `session end`.",
227            session_id
228        );
229    }
230
231    let config = ReplayConfig {
232        auto_checkpoint_interval: args.auto_checkpoint,
233        ..Default::default()
234    };
235
236    let session_id = mem.start_session(args.name, Some(config))?;
237
238    // Save the active session to a sidecar file so it persists across commands
239    mem.save_active_session()?;
240
241    println!("Started session: {}", session_id);
242    println!("Recording is now active. Run operations like put, find, ask.");
243    println!("End the session with: memvid session end {}", args.file);
244
245    Ok(())
246}
247
248#[cfg(feature = "replay")]
249fn handle_session_end(args: SessionEndArgs) -> Result<()> {
250    let mut mem = Memvid::open(&args.file)?;
251
252    // Load the active session from the sidecar file
253    if !mem.load_active_session()? {
254        anyhow::bail!("No active session found. Start one with `session start`.");
255    }
256
257    let session = mem.end_session()?;
258    println!("Ended session: {}", session.session_id);
259    println!("  Actions recorded: {}", session.actions.len());
260    println!("  Checkpoints: {}", session.checkpoints.len());
261    println!("  Duration: {}s", session.duration_secs());
262
263    // First commit to finalize any pending Tantivy/index data
264    // This ensures data_end is accurate before saving replay segment
265    mem.commit()?;
266
267    // Now save the replay sessions after all other data is finalized
268    mem.save_replay_sessions()?;
269
270    // Commit again to persist the replay manifest in TOC
271    mem.commit()?;
272
273    // Remove the sidecar file
274    mem.clear_active_session_file()?;
275
276    println!("Session saved to file.");
277    Ok(())
278}
279
280#[cfg(feature = "replay")]
281fn handle_session_list(args: SessionListArgs) -> Result<()> {
282    let mut mem = Memvid::open(&args.file)?;
283
284    // Check for active session
285    let has_active = mem.load_active_session()?;
286
287    // Load completed sessions from file
288    mem.load_replay_sessions()?;
289
290    let sessions = mem.list_sessions();
291
292    if !has_active && sessions.is_empty() {
293        println!("No recorded sessions.");
294        return Ok(());
295    }
296
297    if args.format == "json" {
298        let json = serde_json::to_string_pretty(&sessions)?;
299        println!("{}", json);
300    } else {
301        // Show active session first if present
302        if has_active {
303            if let Some(session_id) = mem.active_session_id() {
304                println!("Active session (recording):");
305                println!("  {} - currently recording", session_id);
306                println!();
307            }
308        }
309
310        if !sessions.is_empty() {
311            println!("Completed sessions ({}):", sessions.len());
312            for summary in sessions {
313                let name = summary.name.as_deref().unwrap_or("(unnamed)");
314                let status = if summary.ended_secs.is_some() {
315                    "completed"
316                } else {
317                    "recording"
318                };
319                println!(
320                    "  {} - {} [{}, {} actions, {} checkpoints]",
321                    summary.session_id, name, status, summary.action_count, summary.checkpoint_count
322                );
323            }
324        }
325    }
326
327    Ok(())
328}
329
330#[cfg(feature = "replay")]
331fn handle_session_view(args: SessionViewArgs) -> Result<()> {
332    let mut mem = Memvid::open(&args.file)?;
333
334    // Load sessions from file
335    mem.load_replay_sessions()?;
336
337    let session_id: uuid::Uuid = args.session.parse()?;
338    let session = mem
339        .get_session(session_id)
340        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_id))?;
341
342    if args.format == "json" {
343        let json = serde_json::to_string_pretty(&session)?;
344        println!("{}", json);
345    } else {
346        let name = session.name.as_deref().unwrap_or("(unnamed)");
347        println!("Session: {} - {}", session.session_id, name);
348        println!("  Created: {}", session.created_secs);
349        if let Some(ended) = session.ended_secs {
350            println!("  Ended: {}", ended);
351            println!("  Duration: {}s", session.duration_secs());
352        } else {
353            println!("  Status: recording");
354        }
355        println!("  Actions: {}", session.actions.len());
356        println!("  Checkpoints: {}", session.checkpoints.len());
357
358        if !session.actions.is_empty() {
359            println!("\nActions:");
360            for (i, action) in session.actions.iter().enumerate().take(20) {
361                println!(
362                    "  [{}] {} - {:?}",
363                    i, action.action_type.name(), action.action_type
364                );
365            }
366            if session.actions.len() > 20 {
367                println!("  ... and {} more", session.actions.len() - 20);
368            }
369        }
370    }
371
372    Ok(())
373}
374
375#[cfg(feature = "replay")]
376fn handle_session_checkpoint(args: SessionCheckpointArgs) -> Result<()> {
377    let mut mem = Memvid::open(&args.file)?;
378
379    let checkpoint_id = mem.create_checkpoint()?;
380    println!("Created checkpoint: {}", checkpoint_id);
381
382    Ok(())
383}
384
385#[cfg(feature = "replay")]
386fn handle_session_delete(args: SessionDeleteArgs) -> Result<()> {
387    let mut mem = Memvid::open(&args.file)?;
388
389    // Load sessions from file
390    mem.load_replay_sessions()?;
391
392    let session_id: uuid::Uuid = args.session.parse()?;
393    mem.delete_session(session_id)?;
394
395    // Save updated sessions
396    mem.save_replay_sessions()?;
397    mem.commit()?;
398
399    println!("Deleted session: {}", session_id);
400    Ok(())
401}
402
403#[cfg(feature = "replay")]
404fn handle_session_save(args: SessionSaveArgs) -> Result<()> {
405    let mut mem = Memvid::open(&args.file)?;
406
407    // Load any existing sessions from file first
408    mem.load_replay_sessions()?;
409
410    // Check if there are any sessions to save
411    let session_count = mem.list_sessions().len();
412    if session_count == 0 {
413        println!("No sessions to save. Sessions are automatically saved when you run `session end`.");
414        return Ok(());
415    }
416
417    // Re-save all sessions (useful if you added sessions via API or need to re-persist)
418    mem.save_replay_sessions()?;
419    mem.commit()?;
420
421    println!("Saved {} session(s) to file.", session_count);
422    Ok(())
423}
424
425#[cfg(feature = "replay")]
426fn handle_session_load(args: SessionLoadArgs) -> Result<()> {
427    let mut mem = Memvid::open(&args.file)?;
428
429    mem.load_replay_sessions()?;
430
431    let sessions = mem.list_sessions();
432    println!("Loaded {} sessions from file.", sessions.len());
433
434    Ok(())
435}
436
437#[cfg(feature = "replay")]
438fn handle_session_replay(args: SessionReplayArgs) -> Result<()> {
439    // Check if web UI is requested
440    #[cfg(feature = "web")]
441    if args.web {
442        use crate::commands::session_web::start_web_server;
443        use std::path::PathBuf;
444
445        let rt = tokio::runtime::Runtime::new()?;
446        return rt.block_on(start_web_server(
447            args.session.clone(),
448            PathBuf::from(&args.file),
449            args.port,
450            !args.no_open,
451        ));
452    }
453
454    use memvid_core::replay::{ReplayEngine, ReplayExecutionConfig};
455
456    // Use open_read_only to match how find command opens files
457    // This ensures lex_enabled is properly set from has_lex_index()
458    let mut mem = Memvid::open_read_only(&args.file)?;
459
460    // Load sessions from file
461    mem.load_replay_sessions()?;
462
463    let session_id: uuid::Uuid = args.session.parse()?;
464    let session = mem
465        .get_session(session_id)
466        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_id))?
467        .clone();
468
469    let config = ReplayExecutionConfig {
470        skip_puts: args.skip_mutations,
471        skip_finds: args.skip_finds,
472        skip_asks: args.skip_asks,
473        stop_on_mismatch: args.stop_on_mismatch,
474        verbose: true,
475        top_k: args.top_k,
476        adaptive: args.adaptive,
477        min_relevancy: args.min_relevancy,
478    };
479
480    let mut engine = ReplayEngine::new(&mut mem, config);
481    let result = engine.replay_session_from(&session, args.from_checkpoint)?;
482
483    if args.format == "json" {
484        let json = serde_json::to_string_pretty(&result)?;
485        println!("{}", json);
486    } else {
487        println!("{} {}", "Replay Results for Session:".bold(), session_id.to_string().cyan());
488        println!("  Total actions: {}", result.total_actions.to_string().white());
489        println!("  Matched: {}", result.matched_actions.to_string().green());
490        println!("  Mismatched: {}", result.mismatched_actions.to_string().red());
491        println!("  Skipped: {}", result.skipped_actions.to_string().yellow());
492        println!("  Match rate: {}", format!("{:.1}%", result.match_rate()).cyan());
493        println!("  Duration: {}ms", result.total_duration_ms);
494
495        if let Some(cp) = result.from_checkpoint {
496            println!("  Started from checkpoint: {}", cp);
497        }
498
499        if result.is_success() {
500            println!("\n{}", "✓ Replay successful - all actions matched".green().bold());
501        } else {
502            println!("\n{}", "✗ Replay found mismatches:".red().bold());
503            for action_result in result
504                .action_results
505                .iter()
506                .filter(|r| !r.matched && r.diff.as_ref().map_or(true, |d| d != "skipped"))
507            {
508                let diff = action_result.diff.as_deref().unwrap_or("unknown");
509                // Color the diff based on type
510                let colored_diff = if diff.starts_with("FILTER:") {
511                    diff.replace("FILTER:", &"FILTER:".red().bold().to_string())
512                        .replace("would be MISSED", &"would be MISSED".red().to_string())
513                } else if diff.starts_with("DISCOVERY:") {
514                    diff.replace("DISCOVERY:", &"DISCOVERY:".green().bold().to_string())
515                        .replace("[NEW]", &"[NEW]".green().to_string())
516                } else {
517                    diff.to_string()
518                };
519                println!(
520                    "  [{}] {} - {}",
521                    action_result.sequence.to_string().yellow(),
522                    action_result.action_type.cyan(),
523                    colored_diff
524                );
525            }
526        }
527    }
528
529    Ok(())
530}
531
532#[cfg(feature = "replay")]
533fn handle_session_compare(args: SessionCompareArgs) -> Result<()> {
534    use memvid_core::replay::ReplayEngine;
535
536    let mut mem = Memvid::open(&args.file)?;
537
538    // Load sessions from file
539    mem.load_replay_sessions()?;
540
541    let session_a_id: uuid::Uuid = args.session_a.parse()?;
542    let session_b_id: uuid::Uuid = args.session_b.parse()?;
543
544    let session_a = mem
545        .get_session(session_a_id)
546        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_a_id))?
547        .clone();
548
549    let session_b = mem
550        .get_session(session_b_id)
551        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_b_id))?
552        .clone();
553
554    let comparison = ReplayEngine::compare_sessions(&session_a, &session_b);
555
556    if args.format == "json" {
557        let json = serde_json::to_string_pretty(&comparison)?;
558        println!("{}", json);
559    } else {
560        println!("Session Comparison:");
561        println!("  Session A: {}", session_a_id);
562        println!("  Session B: {}", session_b_id);
563        println!();
564
565        if comparison.is_identical() {
566            println!("✓ Sessions are identical");
567            println!("  Matching actions: {}", comparison.matching_actions);
568        } else {
569            println!("✗ Sessions differ:");
570            println!("  Matching actions: {}", comparison.matching_actions);
571
572            if !comparison.actions_only_in_a.is_empty() {
573                println!(
574                    "  Actions only in A: {:?}",
575                    comparison.actions_only_in_a
576                );
577            }
578
579            if !comparison.actions_only_in_b.is_empty() {
580                println!(
581                    "  Actions only in B: {:?}",
582                    comparison.actions_only_in_b
583                );
584            }
585
586            if !comparison.differing_actions.is_empty() {
587                println!("  Differing actions:");
588                for diff in &comparison.differing_actions {
589                    println!(
590                        "    [{}] {} vs {} - {}",
591                        diff.sequence, diff.action_type_a, diff.action_type_b, diff.description
592                    );
593                }
594            }
595        }
596    }
597
598    Ok(())
599}