Răsfoiți Sursa

feat(cli): extend resume commands and add memory inspection

Improve resumed-session parity by letting top-level --resume execute shared read-only commands such as /help, /status, /cost, /config, and /memory in addition to /compact. This makes saved sessions meaningfully inspectable without reopening the interactive REPL.

Also add a genuinely useful /memory command that reports the instruction memory already discovered by the runtime from INSTRUCTIONS.md-style files in the current directory ancestry. The command stays honest by surfacing file paths, line counts, and a short preview instead of inventing unsupported persistent memory behavior.

Constraint: Resume-path improvements must operate safely on saved sessions without requiring a live model runtime

Constraint: /memory must expose real repository instruction context rather than placeholder state

Rejected: Invent editable or persistent chat memory storage | no such durable feature exists in this repo yet

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Reuse shared slash parsing for resume-path features so saved-session commands and REPL commands stay aligned

Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace

Not-tested: manual resume against a diverse set of historical session files from real user workflows
Yeachan-Heo 2 luni în urmă
părinte
comite
07a241babd
2 a modificat fișierele cu 158 adăugiri și 48 ștergeri
  1. 11 1
      rust/crates/commands/src/lib.rs
  2. 147 47
      rust/crates/rusty-claude-cli/src/main.rs

+ 11 - 1
rust/crates/commands/src/lib.rs

@@ -83,6 +83,11 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         summary: "Inspect discovered Claude config files",
         argument_hint: None,
     },
+    SlashCommandSpec {
+        name: "memory",
+        summary: "Inspect loaded Claude instruction memory files",
+        argument_hint: None,
+    },
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -96,6 +101,7 @@ pub enum SlashCommand {
     Cost,
     Resume { session_path: Option<String> },
     Config,
+    Memory,
     Unknown(String),
 }
 
@@ -125,6 +131,7 @@ impl SlashCommand {
                 session_path: parts.next().map(ToOwned::to_owned),
             },
             "config" => Self::Config,
+            "memory" => Self::Memory,
             other => Self::Unknown(other.to_string()),
         })
     }
@@ -187,6 +194,7 @@ pub fn handle_slash_command(
         | SlashCommand::Cost
         | SlashCommand::Resume { .. }
         | SlashCommand::Config
+        | SlashCommand::Memory
         | SlashCommand::Unknown(_) => None,
     }
 }
@@ -227,6 +235,7 @@ mod tests {
             })
         );
         assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
+        assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
     }
 
     #[test]
@@ -241,7 +250,8 @@ mod tests {
         assert!(help.contains("/cost"));
         assert!(help.contains("/resume <session-path>"));
         assert!(help.contains("/config"));
-        assert_eq!(slash_command_specs().len(), 9);
+        assert!(help.contains("/memory"));
+        assert_eq!(slash_command_specs().len(), 10);
     }
 
     #[test]

+ 147 - 47
rust/crates/rusty-claude-cli/src/main.rs

@@ -17,7 +17,8 @@ use render::{Spinner, TerminalRenderer};
 use runtime::{
     load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
     ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
-    PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
+    PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
+    ToolExecutor, UsageTracker,
 };
 use tools::{execute_tool, mvp_tool_specs};
 
@@ -205,27 +206,20 @@ fn resume_session(session_path: &Path, command: Option<String>) {
         }
     };
 
-    match command {
-        Some(command) if command.starts_with('/') => {
-            let Some(result) = handle_slash_command(
-                &command,
-                &session,
-                CompactionConfig {
-                    max_estimated_tokens: 0,
-                    ..CompactionConfig::default()
-                },
-            ) else {
-                eprintln!("unknown slash command: {command}");
+    match command.as_deref().and_then(SlashCommand::parse) {
+        Some(command) => match run_resume_command(session_path, &session, &command) {
+            Ok(Some(message)) => println!("{message}"),
+            Ok(None) => {}
+            Err(error) => {
+                eprintln!("{error}");
                 std::process::exit(2);
-            };
-            if let Err(error) = result.session.save_to_path(session_path) {
-                eprintln!("failed to persist resumed session: {error}");
-                std::process::exit(1);
             }
-            println!("{}", result.message);
-        }
-        Some(other) => {
-            eprintln!("unsupported resumed command: {other}");
+        },
+        None if command.is_some() => {
+            eprintln!(
+                "unsupported resumed command: {}",
+                command.unwrap_or_default()
+            );
             std::process::exit(2);
         }
         None => {
@@ -238,6 +232,60 @@ fn resume_session(session_path: &Path, command: Option<String>) {
     }
 }
 
+fn run_resume_command(
+    session_path: &Path,
+    session: &Session,
+    command: &SlashCommand,
+) -> Result<Option<String>, Box<dyn std::error::Error>> {
+    match command {
+        SlashCommand::Help => Ok(Some(render_repl_help())),
+        SlashCommand::Compact => {
+            let Some(result) = handle_slash_command(
+                "/compact",
+                session,
+                CompactionConfig {
+                    max_estimated_tokens: 0,
+                    ..CompactionConfig::default()
+                },
+            ) else {
+                return Ok(None);
+            };
+            result.session.save_to_path(session_path)?;
+            Ok(Some(result.message))
+        }
+        SlashCommand::Status => {
+            let usage = UsageTracker::from_session(session).cumulative_usage();
+            Ok(Some(format_status_line(
+                "restored-session",
+                session.messages.len(),
+                UsageTracker::from_session(session).turns(),
+                UsageTracker::from_session(session).current_turn_usage(),
+                usage,
+                0,
+                permission_mode_label(),
+            )))
+        }
+        SlashCommand::Cost => {
+            let usage = UsageTracker::from_session(session).cumulative_usage();
+            Ok(Some(format!(
+                "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
+                usage.input_tokens,
+                usage.output_tokens,
+                usage.cache_creation_input_tokens,
+                usage.cache_read_input_tokens,
+                usage.total_tokens(),
+            )))
+        }
+        SlashCommand::Config => Ok(Some(render_config_report()?)),
+        SlashCommand::Memory => Ok(Some(render_memory_report()?)),
+        SlashCommand::Resume { .. }
+        | SlashCommand::Model { .. }
+        | SlashCommand::Permissions { .. }
+        | SlashCommand::Clear
+        | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
+    }
+}
+
 fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
     let mut cli = LiveCli::new(model, true)?;
     let editor = input::LineEditor::new("› ");
@@ -328,6 +376,7 @@ impl LiveCli {
             SlashCommand::Cost => self.print_cost(),
             SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
             SlashCommand::Config => Self::print_config()?,
+            SlashCommand::Memory => Self::print_memory()?,
             SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
         }
         Ok(())
@@ -444,34 +493,12 @@ impl LiveCli {
     }
 
     fn print_config() -> Result<(), Box<dyn std::error::Error>> {
-        let cwd = env::current_dir()?;
-        let loader = ConfigLoader::default_for(&cwd);
-        let discovered = loader.discover();
-        let runtime_config = loader.load()?;
+        println!("{}", render_config_report()?);
+        Ok(())
+    }
 
-        println!(
-            "config: loaded_files={} merged_keys={}",
-            runtime_config.loaded_entries().len(),
-            runtime_config.merged().len()
-        );
-        for entry in discovered {
-            let source = match entry.source {
-                ConfigSource::User => "user",
-                ConfigSource::Project => "project",
-                ConfigSource::Local => "local",
-            };
-            let status = if runtime_config
-                .loaded_entries()
-                .iter()
-                .any(|loaded_entry| loaded_entry.path == entry.path)
-            {
-                "loaded"
-            } else {
-                "missing"
-            };
-            println!("  {source:<7} {status:<7} {}", entry.path.display());
-        }
-        println!("  merged   {}", runtime_config.as_json().render());
+    fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
+        println!("{}", render_memory_report()?);
         Ok(())
     }
 
@@ -516,6 +543,77 @@ fn format_status_line(
     )
 }
 
+fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
+    let cwd = env::current_dir()?;
+    let loader = ConfigLoader::default_for(&cwd);
+    let discovered = loader.discover();
+    let runtime_config = loader.load()?;
+
+    let mut lines = vec![format!(
+        "config: loaded_files={} merged_keys={}",
+        runtime_config.loaded_entries().len(),
+        runtime_config.merged().len()
+    )];
+    for entry in discovered {
+        let source = match entry.source {
+            ConfigSource::User => "user",
+            ConfigSource::Project => "project",
+            ConfigSource::Local => "local",
+        };
+        let status = if runtime_config
+            .loaded_entries()
+            .iter()
+            .any(|loaded_entry| loaded_entry.path == entry.path)
+        {
+            "loaded"
+        } else {
+            "missing"
+        };
+        lines.push(format!(
+            "  {source:<7} {status:<7} {}",
+            entry.path.display()
+        ));
+    }
+    lines.push(format!("  merged   {}", runtime_config.as_json().render()));
+    Ok(lines.join(
+        "
+",
+    ))
+}
+
+fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
+    let project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?;
+    let mut lines = vec![format!(
+        "memory: files={}",
+        project_context.instruction_files.len()
+    )];
+    if project_context.instruction_files.is_empty() {
+        lines.push(
+            "  No CLAUDE instruction files discovered in the current directory ancestry."
+                .to_string(),
+        );
+    } else {
+        for file in project_context.instruction_files {
+            let preview = file.content.lines().next().unwrap_or("").trim();
+            let preview = if preview.is_empty() {
+                "<empty>"
+            } else {
+                preview
+            };
+            lines.push(format!(
+                "  {} ({}) {}",
+                file.path.display(),
+                file.content.lines().count(),
+                preview
+            ));
+        }
+    }
+    Ok(lines.join(
+        "
+",
+    ))
+}
+
 fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
     match mode.trim() {
         "read-only" => Some("read-only"),
@@ -930,6 +1028,7 @@ mod tests {
         assert!(help.contains("/cost"));
         assert!(help.contains("/resume <session-path>"));
         assert!(help.contains("/config"));
+        assert!(help.contains("/memory"));
         assert!(help.contains("/exit"));
     }
 
@@ -984,6 +1083,7 @@ mod tests {
             })
         );
         assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
+        assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
     }
 
     #[test]