返回 CodeWhale
memory.rs
根目录 / crates / tui / src / commands / groups / memory / memory.rs
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
347 lines RUST