Skip to main content

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;
9#[cfg(feature = "replay")]
10use memvid_ask_model::run_model_inference;
11#[cfg(feature = "replay")]
12use memvid_core::replay::ActionType;
13use memvid_core::Memvid;
14
15/// Session management commands for time-travel replay
16#[derive(Args, Debug)]
17pub struct SessionArgs {
18    #[command(subcommand)]
19    pub command: SessionCommand,
20}
21
22#[derive(Subcommand, Debug)]
23pub enum SessionCommand {
24    /// Start recording a new session
25    Start(SessionStartArgs),
26    /// End the current recording session
27    End(SessionEndArgs),
28    /// List all recorded sessions
29    List(SessionListArgs),
30    /// View details of a specific session
31    View(SessionViewArgs),
32    /// Create a checkpoint in the current session
33    Checkpoint(SessionCheckpointArgs),
34    /// Delete a recorded session
35    Delete(SessionDeleteArgs),
36    /// Save sessions to the memory file
37    Save(SessionSaveArgs),
38    /// Load sessions from the memory file
39    Load(SessionLoadArgs),
40    /// Replay a recorded session and verify consistency
41    Replay(SessionReplayArgs),
42    /// Compare two recorded sessions
43    Compare(SessionCompareArgs),
44}
45
46#[derive(Args, Debug)]
47pub struct SessionStartArgs {
48    /// Path to the memory file
49    pub file: String,
50
51    /// Optional name for the session
52    #[arg(short, long)]
53    pub name: Option<String>,
54
55    /// Auto-checkpoint interval (number of actions between checkpoints, 0 = disabled)
56    #[arg(long, default_value = "0")]
57    pub auto_checkpoint: u64,
58}
59
60#[derive(Args, Debug)]
61pub struct SessionEndArgs {
62    /// Path to the memory file
63    pub file: String,
64}
65
66#[derive(Args, Debug)]
67pub struct SessionListArgs {
68    /// Path to the memory file
69    pub file: String,
70
71    /// Output format (text, json)
72    #[arg(short, long, default_value = "text")]
73    pub format: String,
74}
75
76#[derive(Args, Debug)]
77pub struct SessionViewArgs {
78    /// Path to the memory file
79    pub file: String,
80
81    /// Session ID to view
82    #[arg(short, long)]
83    pub session: String,
84
85    /// Output format (text, json)
86    #[arg(short, long, default_value = "text")]
87    pub format: String,
88}
89
90#[derive(Args, Debug)]
91pub struct SessionCheckpointArgs {
92    /// Path to the memory file
93    pub file: String,
94}
95
96#[derive(Args, Debug)]
97pub struct SessionDeleteArgs {
98    /// Path to the memory file
99    pub file: String,
100
101    /// Session ID to delete
102    #[arg(short, long)]
103    pub session: String,
104}
105
106#[derive(Args, Debug)]
107pub struct SessionSaveArgs {
108    /// Path to the memory file
109    pub file: String,
110}
111
112#[derive(Args, Debug)]
113pub struct SessionLoadArgs {
114    /// Path to the memory file
115    pub file: String,
116}
117
118#[derive(Args, Debug)]
119pub struct SessionReplayArgs {
120    /// Path to the memory file
121    pub file: String,
122
123    /// Session ID to replay
124    #[arg(short, long)]
125    pub session: String,
126
127    /// Start replay from a specific checkpoint
128    #[arg(long)]
129    pub from_checkpoint: Option<u64>,
130
131    /// Number of results to retrieve during replay (default: same as original)
132    /// Use higher values to discover documents that were missed due to low top-k
133    #[arg(short = 'k', long)]
134    pub top_k: Option<usize>,
135
136    /// Use adaptive retrieval that adjusts top-k based on score distribution
137    #[arg(long)]
138    pub adaptive: bool,
139
140    /// Minimum relevancy score for adaptive retrieval (0.0-1.0)
141    #[arg(long, default_value = "0.5")]
142    pub min_relevancy: f32,
143
144    /// Skip put/update/delete actions
145    #[arg(long)]
146    pub skip_mutations: bool,
147
148    /// Skip find/search actions
149    #[arg(long)]
150    pub skip_finds: bool,
151
152    /// Skip ask actions (LLM calls)
153    #[arg(long)]
154    pub skip_asks: bool,
155
156    /// Stop on first mismatch
157    #[arg(long)]
158    pub stop_on_mismatch: bool,
159
160    /// AUDIT MODE: Use frozen retrieval for ASK actions
161    /// Replays use recorded frame IDs instead of re-executing search
162    #[arg(long)]
163    pub audit: bool,
164
165    /// Override model for audit replay (format: "provider:model")
166    /// When set, prepares for LLM re-execution with frozen context
167    #[arg(long)]
168    pub use_model: Option<String>,
169
170    /// Generate diff report comparing original vs new answers
171    #[arg(long)]
172    pub diff: bool,
173
174    /// Output format (text, json)
175    #[arg(short, long, default_value = "text")]
176    pub format: String,
177
178    /// Launch the Time Machine web UI for visual replay
179    #[cfg(feature = "web")]
180    #[arg(long)]
181    pub web: bool,
182
183    /// Port for the Time Machine web server (default: 4242)
184    #[cfg(feature = "web")]
185    #[arg(long, default_value = "4242")]
186    pub port: u16,
187
188    /// Don't auto-open browser when starting web UI
189    #[cfg(feature = "web")]
190    #[arg(long)]
191    pub no_open: bool,
192}
193
194#[derive(Args, Debug)]
195pub struct SessionCompareArgs {
196    /// Path to the memory file
197    pub file: String,
198
199    /// First session ID
200    #[arg(short = 'a', long)]
201    pub session_a: String,
202
203    /// Second session ID
204    #[arg(short = 'b', long)]
205    pub session_b: String,
206
207    /// Output format (text, json)
208    #[arg(short, long, default_value = "text")]
209    pub format: String,
210}
211
212/// Handle session subcommands
213#[cfg(feature = "replay")]
214pub fn handle_session(args: SessionArgs) -> Result<()> {
215    match args.command {
216        SessionCommand::Start(args) => handle_session_start(args),
217        SessionCommand::End(args) => handle_session_end(args),
218        SessionCommand::List(args) => handle_session_list(args),
219        SessionCommand::View(args) => handle_session_view(args),
220        SessionCommand::Checkpoint(args) => handle_session_checkpoint(args),
221        SessionCommand::Delete(args) => handle_session_delete(args),
222        SessionCommand::Save(args) => handle_session_save(args),
223        SessionCommand::Load(args) => handle_session_load(args),
224        SessionCommand::Replay(args) => handle_session_replay(args),
225        SessionCommand::Compare(args) => handle_session_compare(args),
226    }
227}
228
229#[cfg(not(feature = "replay"))]
230pub fn handle_session(_args: SessionArgs) -> Result<()> {
231    bail!("Session recording requires the 'replay' feature. Rebuild with --features replay")
232}
233
234#[cfg(feature = "replay")]
235fn handle_session_start(args: SessionStartArgs) -> Result<()> {
236    use memvid_core::replay::ReplayConfig;
237
238    let mut mem = Memvid::open(&args.file)?;
239
240    // Check if there's already an active session
241    if mem.load_active_session()? {
242        let session_id = mem.active_session_id().unwrap();
243        anyhow::bail!(
244            "Session {} is already active. End it first with `session end`.",
245            session_id
246        );
247    }
248
249    let config = ReplayConfig {
250        auto_checkpoint_interval: args.auto_checkpoint,
251        ..Default::default()
252    };
253
254    let session_id = mem.start_session(args.name, Some(config))?;
255
256    // Save the active session to a sidecar file so it persists across commands
257    mem.save_active_session()?;
258
259    println!("Started session: {}", session_id);
260    println!("Recording is now active. Run operations like put, find, ask.");
261    println!("End the session with: memvid session end {}", args.file);
262
263    Ok(())
264}
265
266#[cfg(feature = "replay")]
267fn handle_session_end(args: SessionEndArgs) -> Result<()> {
268    let mut mem = Memvid::open(&args.file)?;
269
270    // Load the active session from the sidecar file
271    if !mem.load_active_session()? {
272        anyhow::bail!("No active session found. Start one with `session start`.");
273    }
274
275    let session = mem.end_session()?;
276    println!("Ended session: {}", session.session_id);
277    println!("  Actions recorded: {}", session.actions.len());
278    println!("  Checkpoints: {}", session.checkpoints.len());
279    println!("  Duration: {}s", session.duration_secs());
280
281    // First commit to finalize any pending Tantivy/index data
282    // This ensures data_end is accurate before saving replay segment
283    mem.commit()?;
284
285    // Now save the replay sessions after all other data is finalized
286    mem.save_replay_sessions()?;
287
288    // Commit again to persist the replay manifest in TOC
289    mem.commit()?;
290
291    // Remove the sidecar file
292    mem.clear_active_session_file()?;
293
294    println!("Session saved to file.");
295    Ok(())
296}
297
298#[cfg(feature = "replay")]
299fn handle_session_list(args: SessionListArgs) -> Result<()> {
300    let mut mem = Memvid::open(&args.file)?;
301
302    // Check for active session
303    let has_active = mem.load_active_session()?;
304
305    // Load completed sessions from file
306    mem.load_replay_sessions()?;
307
308    let sessions = mem.list_sessions();
309
310    if !has_active && sessions.is_empty() {
311        println!("No recorded sessions.");
312        return Ok(());
313    }
314
315    if args.format == "json" {
316        let json = serde_json::to_string_pretty(&sessions)?;
317        println!("{}", json);
318    } else {
319        // Show active session first if present
320        if has_active {
321            if let Some(session_id) = mem.active_session_id() {
322                println!("Active session (recording):");
323                println!("  {} - currently recording", session_id);
324                println!();
325            }
326        }
327
328        if !sessions.is_empty() {
329            println!("Completed sessions ({}):", sessions.len());
330            for summary in sessions {
331                let name = summary.name.as_deref().unwrap_or("(unnamed)");
332                let status = if summary.ended_secs.is_some() {
333                    "completed"
334                } else {
335                    "recording"
336                };
337                println!(
338                    "  {} - {} [{}, {} actions, {} checkpoints]",
339                    summary.session_id,
340                    name,
341                    status,
342                    summary.action_count,
343                    summary.checkpoint_count
344                );
345            }
346        }
347    }
348
349    Ok(())
350}
351
352#[cfg(feature = "replay")]
353fn handle_session_view(args: SessionViewArgs) -> Result<()> {
354    let mut mem = Memvid::open(&args.file)?;
355
356    // Load sessions from file
357    mem.load_replay_sessions()?;
358
359    let session_id: uuid::Uuid = args.session.parse()?;
360    let session = mem
361        .get_session(session_id)
362        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_id))?;
363
364    if args.format == "json" {
365        let json = serde_json::to_string_pretty(&session)?;
366        println!("{}", json);
367    } else {
368        let name = session.name.as_deref().unwrap_or("(unnamed)");
369        println!("Session: {} - {}", session.session_id, name);
370        println!("  Created: {}", session.created_secs);
371        if let Some(ended) = session.ended_secs {
372            println!("  Ended: {}", ended);
373            println!("  Duration: {}s", session.duration_secs());
374        } else {
375            println!("  Status: recording");
376        }
377        println!("  Actions: {}", session.actions.len());
378        println!("  Checkpoints: {}", session.checkpoints.len());
379
380        if !session.actions.is_empty() {
381            println!("\nActions:");
382            for (i, action) in session.actions.iter().enumerate().take(20) {
383                println!(
384                    "  [{}] {} - {:?}",
385                    i,
386                    action.action_type.name(),
387                    action.action_type
388                );
389            }
390            if session.actions.len() > 20 {
391                println!("  ... and {} more", session.actions.len() - 20);
392            }
393        }
394    }
395
396    Ok(())
397}
398
399#[cfg(feature = "replay")]
400fn handle_session_checkpoint(args: SessionCheckpointArgs) -> Result<()> {
401    let mut mem = Memvid::open(&args.file)?;
402
403    let checkpoint_id = mem.create_checkpoint()?;
404    println!("Created checkpoint: {}", checkpoint_id);
405
406    Ok(())
407}
408
409#[cfg(feature = "replay")]
410fn handle_session_delete(args: SessionDeleteArgs) -> Result<()> {
411    let mut mem = Memvid::open(&args.file)?;
412
413    // Load sessions from file
414    mem.load_replay_sessions()?;
415
416    let session_id: uuid::Uuid = args.session.parse()?;
417    mem.delete_session(session_id)?;
418
419    // Save updated sessions
420    mem.save_replay_sessions()?;
421    mem.commit()?;
422
423    println!("Deleted session: {}", session_id);
424    Ok(())
425}
426
427#[cfg(feature = "replay")]
428fn handle_session_save(args: SessionSaveArgs) -> Result<()> {
429    let mut mem = Memvid::open(&args.file)?;
430
431    // Load any existing sessions from file first
432    mem.load_replay_sessions()?;
433
434    // Check if there are any sessions to save
435    let session_count = mem.list_sessions().len();
436    if session_count == 0 {
437        println!(
438            "No sessions to save. Sessions are automatically saved when you run `session end`."
439        );
440        return Ok(());
441    }
442
443    // Re-save all sessions (useful if you added sessions via API or need to re-persist)
444    mem.save_replay_sessions()?;
445    mem.commit()?;
446
447    println!("Saved {} session(s) to file.", session_count);
448    Ok(())
449}
450
451#[cfg(feature = "replay")]
452fn handle_session_load(args: SessionLoadArgs) -> Result<()> {
453    let mut mem = Memvid::open(&args.file)?;
454
455    mem.load_replay_sessions()?;
456
457    let sessions = mem.list_sessions();
458    println!("Loaded {} sessions from file.", sessions.len());
459
460    Ok(())
461}
462
463#[cfg(feature = "replay")]
464fn handle_session_replay(args: SessionReplayArgs) -> Result<()> {
465    // Check if web UI is requested
466    #[cfg(feature = "web")]
467    if args.web {
468        use crate::commands::session_web::start_web_server;
469        use std::path::PathBuf;
470
471        let rt = tokio::runtime::Runtime::new()?;
472        return rt.block_on(start_web_server(
473            args.session.clone(),
474            PathBuf::from(&args.file),
475            args.port,
476            !args.no_open,
477        ));
478    }
479
480    use memvid_core::replay::{ReplayEngine, ReplayExecutionConfig};
481
482    // Use open_read_only to match how find command opens files
483    // This ensures lex_enabled is properly set from has_lex_index()
484    let mut mem = Memvid::open_read_only(&args.file)?;
485
486    // Load sessions from file
487    mem.load_replay_sessions()?;
488
489    let session_id: uuid::Uuid = args.session.parse()?;
490    let session = mem
491        .get_session(session_id)
492        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_id))?
493        .clone();
494
495    let config = ReplayExecutionConfig {
496        skip_puts: args.skip_mutations,
497        skip_finds: args.skip_finds,
498        skip_asks: args.skip_asks,
499        stop_on_mismatch: args.stop_on_mismatch,
500        verbose: true,
501        top_k: args.top_k,
502        adaptive: args.adaptive,
503        min_relevancy: args.min_relevancy,
504        audit_mode: args.audit,
505        use_model: args.use_model.clone(),
506        generate_diff: args.diff,
507    };
508
509    let mut engine = ReplayEngine::new(&mut mem, config);
510    let result = engine.replay_session_from(&session, args.from_checkpoint)?;
511
512    if args.format == "json" {
513        let json = serde_json::to_string_pretty(&result)?;
514        println!("{}", json);
515    } else {
516        println!();
517        println!("{}", "━".repeat(60).dimmed());
518        println!(
519            "{} {}",
520            "Replaying session:".bold(),
521            session_id.to_string().cyan()
522        );
523        println!("{} {}", "File:".dimmed(), args.file);
524
525        // Show mode based on flags
526        let mode_str = if args.audit {
527            "Audit (frozen retrieval)".green().to_string()
528        } else {
529            "Debug (showing recorded actions)".white().to_string()
530        };
531        println!("{} {}", "Mode:".dimmed(), mode_str);
532
533        // Show model override if set
534        if let Some(ref model) = args.use_model {
535            println!("{} {}", "Model Override:".dimmed(), model.yellow());
536        }
537
538        println!("{}", "━".repeat(60).dimmed());
539        println!();
540
541        // Show all actions with their details
542        let total = result.action_results.len();
543        for (action_idx, action_result) in result.action_results.iter().enumerate() {
544            let seq = action_result.sequence;
545            let action_type = &action_result.action_type;
546            let diff = action_result.diff.as_deref().unwrap_or("");
547
548            // Skip "skipped" actions in display unless they're ASK
549            if diff == "skipped" && action_type != "ASK" {
550                continue;
551            }
552
553            // Format based on action type
554            if action_type == "ASK" {
555                // Rich output for ASK actions
556                let status = if action_result.matched {
557                    "✓".green()
558                } else {
559                    "✗".red()
560                };
561                println!(
562                    "{} Step {}/{} {} {}",
563                    status,
564                    (seq + 1).to_string().yellow(),
565                    total,
566                    "ask".cyan().bold(),
567                    ""
568                );
569                // Print multiline diff with proper indentation
570                for line in diff.lines() {
571                    println!("         {}", line);
572                }
573
574                // If audit mode with use_model, re-execute LLM with frozen context
575                if args.audit && args.use_model.is_some() {
576                    if let Some(original_action) = session.actions.get(action_idx) {
577                        if let ActionType::Ask { query, .. } = &original_action.action_type {
578                            let frame_ids = &original_action.affected_frames;
579                            if !frame_ids.is_empty() {
580                                // Build context from frozen frames
581                                let mut context_parts = Vec::new();
582                                for (idx, frame_id) in frame_ids.iter().enumerate() {
583                                    if let Ok(content) = mem.frame_text_by_id(*frame_id) {
584                                        // Truncate to reasonable size
585                                        let snippet = if content.len() > 1000 {
586                                            format!("{}...", &content[..1000])
587                                        } else {
588                                            content
589                                        };
590                                        context_parts.push(format!("[{}] {}", idx + 1, snippet));
591                                    }
592                                }
593                                let frozen_context = context_parts.join("\n\n");
594
595                                // Call LLM with override model
596                                let model_name = args.use_model.as_ref().unwrap();
597                                match run_model_inference(
598                                    model_name,
599                                    query,
600                                    &frozen_context,
601                                    &[], // No hits needed
602                                    None,
603                                    None,
604                                    None,
605                                ) {
606                                    Ok(inference) => {
607                                        let new_answer = &inference.answer.answer;
608                                        let original_answer = &original_action.output_preview;
609
610                                        // Show the new answer
611                                        println!(
612                                            "         {} \"{}\"",
613                                            "New Answer:".green().bold(),
614                                            if new_answer.len() > 150 {
615                                                format!("{}...", &new_answer[..150])
616                                            } else {
617                                                new_answer.clone()
618                                            }
619                                        );
620
621                                        // Show diff if requested
622                                        if args.diff {
623                                            let same = new_answer.trim() == original_answer.trim();
624                                            if same {
625                                                println!(
626                                                    "         {} {}",
627                                                    "Diff:".dimmed(),
628                                                    "IDENTICAL".green()
629                                                );
630                                            } else {
631                                                println!(
632                                                    "         {} {}",
633                                                    "Diff:".dimmed(),
634                                                    "CHANGED".yellow()
635                                                );
636                                            }
637                                        }
638                                    }
639                                    Err(e) => {
640                                        println!("         {} {}", "LLM Error:".red(), e);
641                                    }
642                                }
643                            }
644                        }
645                    }
646                }
647                println!();
648            } else if action_type == "FIND" && diff.contains("DISCOVERY:")
649                || diff.contains("FILTER:")
650            {
651                // Rich output for FIND with discoveries
652                let colored_diff = if diff.starts_with("FILTER:") {
653                    diff.replace("FILTER:", &"FILTER:".red().bold().to_string())
654                        .replace("would be MISSED", &"would be MISSED".red().to_string())
655                } else if diff.starts_with("DISCOVERY:") {
656                    diff.replace("DISCOVERY:", &"DISCOVERY:".green().bold().to_string())
657                        .replace("[NEW]", &"[NEW]".green().to_string())
658                } else {
659                    diff.to_string()
660                };
661                println!(
662                    "  Step {}/{} {} - {}",
663                    (seq + 1).to_string().yellow(),
664                    total,
665                    action_type.cyan(),
666                    colored_diff
667                );
668            } else if !diff.is_empty() && diff != "skipped" {
669                // Standard output for other actions with diffs
670                let status = if action_result.matched { "✓" } else { "✗" };
671                println!(
672                    "  {} Step {}/{} {} - {}",
673                    if action_result.matched {
674                        status.green()
675                    } else {
676                        status.red()
677                    },
678                    (seq + 1).to_string().yellow(),
679                    total,
680                    action_type.cyan(),
681                    diff.dimmed()
682                );
683            }
684        }
685
686        println!("{}", "━".repeat(60).dimmed());
687        println!();
688        println!("{}", "Summary:".bold());
689        println!(
690            "  Total actions: {}",
691            result.total_actions.to_string().white()
692        );
693        println!("  Matched: {}", result.matched_actions.to_string().green());
694        println!(
695            "  Mismatched: {}",
696            result.mismatched_actions.to_string().red()
697        );
698        println!("  Skipped: {}", result.skipped_actions.to_string().yellow());
699        println!(
700            "  Match rate: {}",
701            format!("{:.1}%", result.match_rate()).cyan()
702        );
703        println!("  Duration: {}ms", result.total_duration_ms);
704
705        if let Some(cp) = result.from_checkpoint {
706            println!("  Started from checkpoint: {}", cp);
707        }
708
709        println!();
710        if result.is_success() {
711            println!("{}", "✓ Replay completed successfully".green().bold());
712        } else {
713            println!("{}", "✗ Replay found mismatches".red().bold());
714        }
715    }
716
717    Ok(())
718}
719
720#[cfg(feature = "replay")]
721fn handle_session_compare(args: SessionCompareArgs) -> Result<()> {
722    use memvid_core::replay::ReplayEngine;
723
724    let mut mem = Memvid::open(&args.file)?;
725
726    // Load sessions from file
727    mem.load_replay_sessions()?;
728
729    let session_a_id: uuid::Uuid = args.session_a.parse()?;
730    let session_b_id: uuid::Uuid = args.session_b.parse()?;
731
732    let session_a = mem
733        .get_session(session_a_id)
734        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_a_id))?
735        .clone();
736
737    let session_b = mem
738        .get_session(session_b_id)
739        .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_b_id))?
740        .clone();
741
742    let comparison = ReplayEngine::compare_sessions(&session_a, &session_b);
743
744    if args.format == "json" {
745        let json = serde_json::to_string_pretty(&comparison)?;
746        println!("{}", json);
747    } else {
748        println!("Session Comparison:");
749        println!("  Session A: {}", session_a_id);
750        println!("  Session B: {}", session_b_id);
751        println!();
752
753        if comparison.is_identical() {
754            println!("✓ Sessions are identical");
755            println!("  Matching actions: {}", comparison.matching_actions);
756        } else {
757            println!("✗ Sessions differ:");
758            println!("  Matching actions: {}", comparison.matching_actions);
759
760            if !comparison.actions_only_in_a.is_empty() {
761                println!("  Actions only in A: {:?}", comparison.actions_only_in_a);
762            }
763
764            if !comparison.actions_only_in_b.is_empty() {
765                println!("  Actions only in B: {:?}", comparison.actions_only_in_b);
766            }
767
768            if !comparison.differing_actions.is_empty() {
769                println!("  Differing actions:");
770                for diff in &comparison.differing_actions {
771                    println!(
772                        "    [{}] {} vs {} - {}",
773                        diff.sequence, diff.action_type_a, diff.action_type_b, diff.description
774                    );
775                }
776            }
777        }
778    }
779
780    Ok(())
781}