| 1 | //! `/memory` slash command — inspect and edit the user memory file. |
| 2 | //! |
| 3 | //! When the user-memory feature is opted-in (`[memory] enabled = true` in |
| 4 | //! config or `DEEPSEEK_MEMORY=on` in the environment), `/memory` shows |
| 5 | //! the current memory file path and contents inline. Subcommands let the |
| 6 | //! user clear or open the file: |
| 7 | //! |
| 8 | //! - `/memory` — show path + content |
| 9 | //! - `/memory show` — alias for the no-arg form |
| 10 | //! - `/memory clear` — replace the file contents with an empty marker |
| 11 | //! - `/memory path` — show only the resolved path |
| 12 | //! - `/memory help` — show command-specific help and the resolved path |
| 13 | //! |
| 14 | //! Editor integration (`/memory edit`) is intentionally minimal: the |
| 15 | //! command prints a copy-pasteable shell line to open the file in the |
| 16 | //! user's `$VISUAL` / `$EDITOR`, since the in-process external editor |
| 17 | //! plumbing requires terminal teardown that the slash-command handler |
| 18 | //! doesn't have access to. |
| 19 | |
| 20 | use std::fs; |
| 21 | use std::path::Path; |
| 22 | |
| 23 | use crate::commands::CommandResult; |
| 24 | use crate::tui::app::App; |
| 25 | |
| 26 | const MEMORY_USAGE: &str = "/memory [show|path|clear|edit|native ...|help]"; |
| 27 | |
| 28 | fn memory_help(path: &Path) -> String { |
| 29 | format!( |
| 30 | "Inspect or manage your persistent user-memory file.\n\n\ |
| 31 | Usage: {MEMORY_USAGE}\n\n\ |
| 32 | Current path: {}\n\n\ |
| 33 | Subcommands:\n\ |
| 34 | /memory Show the resolved path and current contents\n\ |
| 35 | /memory show Alias for the no-arg form\n\ |
| 36 | /memory path Print just the resolved path\n\ |
| 37 | /memory clear Replace the file contents with an empty marker\n\ |
| 38 | /memory edit Print the editor command for this file\n\ |
| 39 | /memory native Manage the local-native Markdown + FTS5 store\n\ |
| 40 | /memory help Show this help\n\n\ |
| 41 | Quick capture: type `# foo` in the composer to append a timestamped\n\ |
| 42 | bullet without firing a turn.", |
| 43 | path.display() |
| 44 | ) |
| 45 | } |
| 46 | |
| 47 | fn native_store(app: &App) -> crate::native_memory::NativeMemoryStore { |
| 48 | if let Some(store) = crate::native_memory::NativeMemoryStore::from_global_path(&app.memory_path) |
| 49 | { |
| 50 | return store; |
| 51 | } |
| 52 | let root = app |
| 53 | .memory_path |
| 54 | .parent() |
| 55 | .unwrap_or_else(|| Path::new(".")) |
| 56 | .join("memory"); |
| 57 | crate::native_memory::NativeMemoryStore::new(root) |
| 58 | } |
| 59 | |
| 60 | fn native_command(app: &App, input: &str) -> CommandResult { |
| 61 | let store = native_store(app); |
| 62 | let mut parts = input.splitn(2, char::is_whitespace); |
| 63 | let command = parts.next().unwrap_or("status"); |
| 64 | let arg = parts |
| 65 | .next() |
| 66 | .map(str::trim) |
| 67 | .filter(|value| !value.is_empty()); |
| 68 | match command { |
| 69 | "status" => CommandResult::message(format!( |
| 70 | "native memory: {}\nsource: {}\nindex: {}", |
| 71 | store.root().display(), |
| 72 | store.global_path().display(), |
| 73 | store.index_path().display() |
| 74 | )), |
| 75 | "path" => CommandResult::message(store.root().display().to_string()), |
| 76 | "search" => { |
| 77 | let Some(query) = arg else { |
| 78 | return CommandResult::error("Usage: /memory native search <query>"); |
| 79 | }; |
| 80 | match store.search_for_workspace(&app.workspace, query, 10) { |
| 81 | Ok(hits) if hits.is_empty() => CommandResult::message("No native memory matches."), |
| 82 | Ok(hits) => CommandResult::message( |
| 83 | hits.into_iter() |
| 84 | .map(|hit| { |
| 85 | format!( |
| 86 | "{}:{}-{} {}", |
| 87 | hit.source.display(), |
| 88 | hit.line_start, |
| 89 | hit.line_end, |
| 90 | hit.text |
| 91 | ) |
| 92 | }) |
| 93 | .collect::<Vec<_>>() |
| 94 | .join("\n"), |
| 95 | ), |
| 96 | Err(err) => CommandResult::error(format!("native memory search failed: {err}")), |
| 97 | } |
| 98 | } |
| 99 | "remember" => { |
| 100 | let Some(input) = arg else { |
| 101 | return CommandResult::error( |
| 102 | "Usage: /memory native remember [global|workspace] <note>", |
| 103 | ); |
| 104 | }; |
| 105 | let mut words = input.splitn(3, char::is_whitespace); |
| 106 | let scope_word = words.next().unwrap_or_default(); |
| 107 | if scope_word == "workspace" { |
| 108 | let Some(note) = words.next() else { |
| 109 | return CommandResult::error("Usage: /memory native remember workspace <note>"); |
| 110 | }; |
| 111 | let workspace_id = |
| 112 | match crate::native_memory::NativeMemoryStore::workspace_id(&app.workspace) { |
| 113 | Ok(Some(id)) => id, |
| 114 | Ok(None) => { |
| 115 | return CommandResult::error( |
| 116 | "workspace memory requires a git repository with an origin", |
| 117 | ); |
| 118 | } |
| 119 | Err(err) => { |
| 120 | return CommandResult::error(format!( |
| 121 | "failed to resolve workspace identity: {err}" |
| 122 | )); |
| 123 | } |
| 124 | }; |
| 125 | match store.remember( |
| 126 | crate::native_memory::MemoryScope::Workspace, |
| 127 | Some(&workspace_id), |
| 128 | note, |
| 129 | ) { |
| 130 | Ok(hit) => CommandResult::message(format!( |
| 131 | "native memory remembered at {}:{}", |
| 132 | hit.source.display(), |
| 133 | hit.line_start |
| 134 | )), |
| 135 | Err(err) => CommandResult::error(format!("native memory write failed: {err}")), |
| 136 | } |
| 137 | } else { |
| 138 | match store.remember(crate::native_memory::MemoryScope::Global, None, input) { |
| 139 | Ok(hit) => CommandResult::message(format!( |
| 140 | "native memory remembered at {}:{}", |
| 141 | hit.source.display(), |
| 142 | hit.line_start |
| 143 | )), |
| 144 | Err(err) => CommandResult::error(format!("native memory write failed: {err}")), |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | "import" => { |
| 149 | let legacy_path = store |
| 150 | .root() |
| 151 | .parent() |
| 152 | .map(|parent| parent.join("memory.md")) |
| 153 | .unwrap_or_else(|| app.memory_path.clone()); |
| 154 | match store.import_legacy(&legacy_path) { |
| 155 | Ok(true) => CommandResult::message(format!( |
| 156 | "legacy memory imported non-destructively into {}", |
| 157 | store.global_path().display() |
| 158 | )), |
| 159 | Ok(false) => { |
| 160 | CommandResult::message("legacy memory was already imported or is empty") |
| 161 | } |
| 162 | Err(err) => CommandResult::error(format!("legacy memory import failed: {err}")), |
| 163 | } |
| 164 | } |
| 165 | "get" => { |
| 166 | let Some(id) = arg.and_then(|value| value.parse::<i64>().ok()) else { |
| 167 | return CommandResult::error("Usage: /memory native get <id>"); |
| 168 | }; |
| 169 | match store.get_for_workspace(&app.workspace, id) { |
| 170 | Ok(Some(hit)) => CommandResult::message(format!( |
| 171 | "{}:{}-{}\n{}", |
| 172 | hit.source.display(), |
| 173 | hit.line_start, |
| 174 | hit.line_end, |
| 175 | hit.text |
| 176 | )), |
| 177 | Ok(None) => CommandResult::error(format!("native memory entry {id} not found")), |
| 178 | Err(err) => CommandResult::error(format!("native memory get failed: {err}")), |
| 179 | } |
| 180 | } |
| 181 | "export" => match store.export() { |
| 182 | Ok(export) if export.is_empty() => CommandResult::message("Native memory is empty."), |
| 183 | Ok(export) => CommandResult::message(export), |
| 184 | Err(err) => CommandResult::error(format!("native memory export failed: {err}")), |
| 185 | }, |
| 186 | "reindex" => match store.reindex() { |
| 187 | Ok(count) => { |
| 188 | CommandResult::message(format!("native memory reindexed: {count} entries")) |
| 189 | } |
| 190 | Err(err) => CommandResult::error(format!("native memory reindex failed: {err}")), |
| 191 | }, |
| 192 | "delete" | "clear" => { |
| 193 | let scope = arg.unwrap_or("all"); |
| 194 | let result = match scope { |
| 195 | "all" => store.delete_all(None, None), |
| 196 | "global" => store.delete_all(Some(crate::native_memory::MemoryScope::Global), None), |
| 197 | "workspace" => { |
| 198 | match crate::native_memory::NativeMemoryStore::workspace_id(&app.workspace) { |
| 199 | Ok(Some(id)) => store.delete_all( |
| 200 | Some(crate::native_memory::MemoryScope::Workspace), |
| 201 | Some(&id), |
| 202 | ), |
| 203 | Ok(None) => Err(anyhow::anyhow!( |
| 204 | "workspace memory requires a git repository with an origin" |
| 205 | )), |
| 206 | Err(err) => Err(err), |
| 207 | } |
| 208 | } |
| 209 | _ => { |
| 210 | return CommandResult::error( |
| 211 | "Usage: /memory native delete [all|global|workspace]", |
| 212 | ); |
| 213 | } |
| 214 | }; |
| 215 | match result { |
| 216 | Ok(()) => CommandResult::message(format!("native memory {scope} deleted")), |
| 217 | Err(err) => CommandResult::error(format!("native memory delete failed: {err}")), |
| 218 | } |
| 219 | } |
| 220 | _ => CommandResult::error( |
| 221 | "Usage: /memory native [status|path|remember ...|import|search <query>|get <id>|export|reindex|delete]", |
| 222 | ), |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 227 | if !app.use_memory { |
| 228 | return CommandResult::error( |
| 229 | "user memory is disabled. Enable with `[memory] enabled = true` in `~/.codewhale/config.toml` or `DEEPSEEK_MEMORY=on` in your environment, then restart the TUI.", |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | let path = app.memory_path.clone(); |
| 234 | let sub = arg.unwrap_or("show").trim(); |
| 235 | |
| 236 | if let Some(native_arg) = sub.strip_prefix("native").map(str::trim) { |
| 237 | return native_command(app, native_arg); |
| 238 | } |
| 239 | |
| 240 | match sub { |
| 241 | "" | "show" => { |
| 242 | let body = match fs::read_to_string(&path) { |
| 243 | Ok(text) if text.trim().is_empty() => format!( |
| 244 | "{}\n(empty — add via `# foo` from the composer or have the model use the `remember` tool)", |
| 245 | path.display() |
| 246 | ), |
| 247 | Ok(text) => format!("{}\n\n{}", path.display(), text.trim_end()), |
| 248 | Err(_) => format!( |
| 249 | "{}\n(file does not exist yet — add via `# foo` from the composer to create it)", |
| 250 | path.display() |
| 251 | ), |
| 252 | }; |
| 253 | CommandResult::message(body) |
| 254 | } |
| 255 | "path" => CommandResult::message(path.display().to_string()), |
| 256 | "clear" => match fs::write(&path, "") { |
| 257 | Ok(()) => CommandResult::message(format!("memory cleared: {}", path.display())), |
| 258 | Err(err) => CommandResult::error(format!("failed to clear {}: {err}", path.display())), |
| 259 | }, |
| 260 | "edit" => CommandResult::message(format!( |
| 261 | "to edit your memory file, run:\n\n ${{VISUAL:-${{EDITOR:-vi}}}} {}", |
| 262 | path.display() |
| 263 | )), |
| 264 | "help" => CommandResult::message(memory_help(&path)), |
| 265 | _ => CommandResult::error(format!( |
| 266 | "unknown subcommand `{sub}`. Try `/memory help`.\n\n{}", |
| 267 | memory_help(&path) |
| 268 | )), |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo = |
| 273 | crate::commands::traits::CommandInfo { |
| 274 | name: "memory", |
| 275 | aliases: &[], |
| 276 | usage: "/memory [show|path|clear|edit|help]", |
| 277 | description_id: crate::localization::MessageId::CmdMemoryDescription, |
| 278 | }; |
| 279 | |
| 280 | pub(in crate::commands) struct MemoryCmd; |
| 281 | |
| 282 | impl crate::commands::traits::RegisterCommand for MemoryCmd { |
| 283 | fn info() -> &'static crate::commands::traits::CommandInfo { |
| 284 | &COMMAND_INFO |
| 285 | } |
| 286 | |
| 287 | fn execute( |
| 288 | app: &mut crate::tui::app::App, |
| 289 | arg: Option<&str>, |
| 290 | ) -> crate::commands::CommandResult { |
| 291 | memory(app, arg) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | #[cfg(test)] |
| 296 | mod tests { |
| 297 | use super::*; |
| 298 | use crate::config::Config; |
| 299 | use crate::tui::app::{App, TuiOptions}; |
| 300 | use tempfile::TempDir; |
| 301 | |
| 302 | fn create_test_app_with_memory(tmpdir: &TempDir, use_memory: bool) -> App { |
| 303 | let options = TuiOptions { |
| 304 | skills_dir: tmpdir.path().join("skills"), |
| 305 | memory_path: tmpdir.path().join("memory.md"), |
| 306 | notes_path: tmpdir.path().join("notes.txt"), |
| 307 | mcp_config_path: tmpdir.path().join("mcp.json"), |
| 308 | use_memory, |
| 309 | ..crate::test_support::test_tui_options(tmpdir.path()) |
| 310 | }; |
| 311 | App::new(options, &Config::default()) |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn memory_help_lists_subcommands_and_resolved_path() { |
| 316 | let tmpdir = TempDir::new().expect("tempdir"); |
| 317 | let mut app = create_test_app_with_memory(&tmpdir, true); |
| 318 | let result = memory(&mut app, Some("help")); |
| 319 | let msg = result.message.expect("help should return text"); |
| 320 | assert!(msg.contains("Usage: /memory [show|path|clear|edit|native ...|help]")); |
| 321 | assert!(msg.contains("/memory edit")); |
| 322 | assert!(msg.contains(app.memory_path.to_string_lossy().as_ref())); |
| 323 | } |
| 324 | |
| 325 | #[test] |
| 326 | fn memory_unknown_subcommand_points_to_help() { |
| 327 | let tmpdir = TempDir::new().expect("tempdir"); |
| 328 | let mut app = create_test_app_with_memory(&tmpdir, true); |
| 329 | let result = memory(&mut app, Some("wat")); |
| 330 | let msg = result |
| 331 | .message |
| 332 | .expect("unknown subcommand should return text"); |
| 333 | assert!(msg.contains("Try `/memory help`")); |
| 334 | assert!(msg.contains("/memory clear")); |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn memory_disabled_returns_enablement_hint() { |
| 339 | let tmpdir = TempDir::new().expect("tempdir"); |
| 340 | let mut app = create_test_app_with_memory(&tmpdir, false); |
| 341 | let result = memory(&mut app, None); |
| 342 | let msg = result.message.expect("disabled memory should return text"); |
| 343 | assert!(msg.contains("user memory is disabled")); |
| 344 | assert!(msg.contains("DEEPSEEK_MEMORY=on")); |
| 345 | } |
| 346 | } |
| 347 |