1use super::traits::{Memory, MemoryCategory};
2use super::{
3 MemoryBackendKind, backend_kind_from_dotted, classify_memory_backend,
4 create_memory_for_migration, create_memory_with_storage_and_routes,
5};
6use crate::config::Config;
7use anyhow::{Result, bail};
8use console::style;
9#[cfg(feature = "agent-runtime")]
10use zeroclaw_runtime::i18n;
11
12pub async fn handle_command(command: crate::MemoryCommands, config: &Config) -> Result<()> {
14 match command {
15 crate::MemoryCommands::List {
16 category,
17 session,
18 limit,
19 offset,
20 } => handle_list(config, category, session, limit, offset).await,
21 crate::MemoryCommands::Get { key } => handle_get(config, &key).await,
22 crate::MemoryCommands::Stats => handle_stats(config).await,
23 crate::MemoryCommands::Clear { key, category, yes } => {
24 handle_clear(config, key, category, yes).await
25 }
26 crate::MemoryCommands::Reindex => handle_reindex(config).await,
27 }
28}
29
30fn create_memory_with_embedder(config: &Config) -> Result<Box<dyn Memory>> {
40 let backend = backend_kind_from_dotted(&config.memory.backend);
41 if matches!(classify_memory_backend(&backend), MemoryBackendKind::None) {
42 bail!("Memory backend is 'none' (disabled). No entries to manage.");
43 }
44 let fallback_api_key = config
45 .first_model_provider()
46 .and_then(|e| e.api_key.as_deref());
47 create_memory_with_storage_and_routes(
48 &config.memory,
49 &config.embedding_routes,
50 config.resolve_active_storage(),
51 &config.data_dir,
52 fallback_api_key,
53 )
54}
55
56async fn handle_reindex(config: &Config) -> Result<()> {
57 let mem = create_memory_with_embedder(config)?;
58 println!("{} Reindexing memory backend...", style("→").cyan());
59 let count = mem.reindex().await?;
60 if count == 0 {
61 println!(
62 "{} FTS rebuilt. No embeddings to fill in (either everything is already embedded or the backend has no embedder configured).",
63 style("✓").green()
64 );
65 } else {
66 println!(
67 "{} FTS rebuilt. Re-embedded {count} {}.",
68 style("✓").green(),
69 if count == 1 { "entry" } else { "entries" }
70 );
71 }
72 Ok(())
73}
74
75fn create_cli_memory(config: &Config) -> Result<Box<dyn Memory>> {
81 let backend = backend_kind_from_dotted(&config.memory.backend);
82
83 match classify_memory_backend(&backend) {
84 MemoryBackendKind::None => {
85 bail!("Memory backend is 'none' (disabled). No entries to manage.");
86 }
87 _ => create_memory_for_migration(&backend, &config.data_dir),
88 }
89}
90
91async fn handle_list(
92 config: &Config,
93 category: Option<String>,
94 session: Option<String>,
95 limit: usize,
96 offset: usize,
97) -> Result<()> {
98 let mem = create_cli_memory(config)?;
99 let cat = category.as_deref().map(parse_category);
100 let entries = mem.list(cat.as_ref(), session.as_deref()).await?;
101
102 if entries.is_empty() {
103 println!("No memory entries found.");
104 return Ok(());
105 }
106
107 let total = entries.len();
108 let page: Vec<_> = entries.into_iter().skip(offset).take(limit).collect();
109
110 if page.is_empty() {
111 println!("No entries at offset {offset} (total: {total}).");
112 return Ok(());
113 }
114
115 println!(
116 "Memory entries ({total} total, showing {}-{}):\n",
117 offset + 1,
118 offset + page.len(),
119 );
120
121 for entry in &page {
122 println!(
123 "- {} [{}]",
124 style(&entry.key).white().bold(),
125 entry.category,
126 );
127 println!(" {}", truncate_content(&entry.content, 80));
128 }
129
130 if offset + page.len() < total {
131 println!("\n Use --offset {} to see the next page.", offset + limit);
132 }
133
134 Ok(())
135}
136
137async fn handle_get(config: &Config, key: &str) -> Result<()> {
138 let mem = create_cli_memory(config)?;
139
140 if let Some(entry) = mem.get(key).await? {
142 print_entry(&entry);
143 return Ok(());
144 }
145
146 let all = mem.list(None, None).await?;
148 let matches: Vec<_> = all.iter().filter(|e| e.key.starts_with(key)).collect();
149
150 match matches.len() {
151 0 => println!("No memory entry found for key: {key}"),
152 1 => print_entry(matches[0]),
153 n => {
154 println!("Prefix '{key}' matched {n} entries:\n");
155 for entry in matches {
156 println!(
157 "- {} [{}]",
158 style(&entry.key).white().bold(),
159 entry.category
160 );
161 }
162 println!("\nSpecify a longer prefix to narrow the match.");
163 }
164 }
165
166 Ok(())
167}
168
169fn print_entry(entry: &super::traits::MemoryEntry) {
170 println!("Key: {}", style(&entry.key).white().bold());
171 println!("Category: {}", entry.category);
172 println!("Timestamp: {}", entry.timestamp);
173 if let Some(sid) = &entry.session_id {
174 println!("Session: {sid}");
175 }
176 println!("\n{}", entry.content);
177}
178
179async fn handle_stats(config: &Config) -> Result<()> {
180 let mem = create_cli_memory(config)?;
181 let healthy = mem.health_check().await;
182 let total = mem.count().await.unwrap_or(0);
183
184 println!("Memory Statistics:\n");
185 println!(" Backend: {}", style(mem.name()).white().bold());
186 println!(
187 " Health: {}",
188 if healthy {
189 style("healthy").green().bold().to_string()
190 } else {
191 style("unhealthy").yellow().bold().to_string()
192 }
193 );
194 println!(" Total: {total}");
195
196 let all = mem.list(None, None).await.unwrap_or_default();
197 if !all.is_empty() {
198 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
199 for entry in &all {
200 *counts.entry(entry.category.to_string()).or_default() += 1;
201 }
202
203 println!("\n By category:");
204 let mut sorted: Vec<_> = counts.into_iter().collect();
205 sorted.sort_by_key(|entry| std::cmp::Reverse(entry.1));
206 for (cat, count) in sorted {
207 println!(" {cat:<20} {count}");
208 }
209 }
210
211 Ok(())
212}
213
214fn unsupported_clear_backend_message(backend: &str) -> String {
215 #[cfg(feature = "agent-runtime")]
216 {
217 i18n::get_required_cli_string_with_args(
218 "cli-memory-clear-unsupported-backend",
219 &[("backend", backend)],
220 )
221 }
222
223 #[cfg(not(feature = "agent-runtime"))]
224 {
225 format!(
226 "memory clear is unsupported for append-only backend '{backend}'; switch to a deletable backend (sqlite, lucid, or postgres)"
227 )
228 }
229}
230
231async fn handle_clear(
232 config: &Config,
233 key: Option<String>,
234 category: Option<String>,
235 yes: bool,
236) -> Result<()> {
237 let backend = backend_kind_from_dotted(&config.memory.backend);
238 if matches!(
239 classify_memory_backend(&backend),
240 MemoryBackendKind::Markdown | MemoryBackendKind::Qdrant
241 ) {
242 bail!(unsupported_clear_backend_message(&backend));
243 }
244 let mem = create_cli_memory(config)?;
245
246 if let Some(key) = key {
248 return handle_clear_key(&*mem, &key, yes).await;
249 }
250
251 let cat = category.as_deref().map(parse_category);
253 let entries = mem.list(cat.as_ref(), None).await?;
254
255 if entries.is_empty() {
256 println!("No entries to clear.");
257 return Ok(());
258 }
259
260 let scope = category.as_deref().unwrap_or("all categories");
261 println!("Found {} entries in '{scope}'.", entries.len());
262
263 if !yes {
264 let confirmed = dialoguer::Confirm::new()
265 .with_prompt(format!(" Delete {} entries?", entries.len()))
266 .default(false)
267 .interact()?;
268 if !confirmed {
269 println!("Aborted.");
270 return Ok(());
271 }
272 }
273
274 let mut deleted = 0usize;
275 for entry in &entries {
276 if mem.forget(&entry.key).await? {
277 deleted += 1;
278 }
279 }
280
281 println!(
282 "{} Cleared {deleted}/{} entries.",
283 style("✓").green().bold(),
284 entries.len(),
285 );
286
287 Ok(())
288}
289
290async fn handle_clear_key(mem: &dyn Memory, key: &str, yes: bool) -> Result<()> {
292 let target = if mem.get(key).await?.is_some() {
294 key.to_string()
295 } else {
296 let all = mem.list(None, None).await?;
297 let matches: Vec<_> = all.iter().filter(|e| e.key.starts_with(key)).collect();
298 match matches.len() {
299 0 => {
300 println!("No memory entry found for key: {key}");
301 return Ok(());
302 }
303 1 => matches[0].key.clone(),
304 n => {
305 println!("Prefix '{key}' matched {n} entries:\n");
306 for entry in matches {
307 println!(
308 "- {} [{}]",
309 style(&entry.key).white().bold(),
310 entry.category
311 );
312 }
313 println!("\nSpecify a longer prefix to narrow the match.");
314 return Ok(());
315 }
316 }
317 };
318
319 if !yes {
320 let confirmed = dialoguer::Confirm::new()
321 .with_prompt(format!(" Delete '{target}'?"))
322 .default(false)
323 .interact()?;
324 if !confirmed {
325 println!("Aborted.");
326 return Ok(());
327 }
328 }
329
330 if mem.forget(&target).await? {
331 println!("{} Deleted key: {target}", style("✓").green().bold());
332 }
333
334 Ok(())
335}
336
337fn parse_category(s: &str) -> MemoryCategory {
338 match s.trim().to_ascii_lowercase().as_str() {
339 "core" => MemoryCategory::Core,
340 "daily" => MemoryCategory::Daily,
341 "conversation" => MemoryCategory::Conversation,
342 other => MemoryCategory::Custom(other.to_string()),
343 }
344}
345
346fn truncate_content(s: &str, max_len: usize) -> String {
347 let line = s.lines().next().unwrap_or(s);
348 if line.len() <= max_len {
349 return line.to_string();
350 }
351 let truncated: String = line.chars().take(max_len.saturating_sub(3)).collect();
352 format!("{truncated}...")
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use tempfile::TempDir;
359
360 #[test]
361 fn parse_category_known_variants() {
362 assert_eq!(parse_category("core"), MemoryCategory::Core);
363 assert_eq!(parse_category("daily"), MemoryCategory::Daily);
364 assert_eq!(parse_category("conversation"), MemoryCategory::Conversation);
365 assert_eq!(parse_category("CORE"), MemoryCategory::Core);
366 assert_eq!(parse_category(" Daily "), MemoryCategory::Daily);
367 }
368
369 #[test]
370 fn parse_category_custom_fallback() {
371 assert_eq!(
372 parse_category("project_notes"),
373 MemoryCategory::Custom("project_notes".into())
374 );
375 }
376
377 #[test]
378 fn truncate_content_short_text_unchanged() {
379 assert_eq!(truncate_content("hello", 10), "hello");
380 }
381
382 #[test]
383 fn truncate_content_long_text_truncated() {
384 let result = truncate_content("this is a very long string", 10);
385 assert!(result.ends_with("..."));
386 assert!(result.chars().count() <= 10);
387 }
388
389 #[test]
390 fn truncate_content_multiline_uses_first_line() {
391 assert_eq!(truncate_content("first\nsecond", 20), "first");
392 }
393
394 #[test]
395 fn truncate_content_empty_string() {
396 assert_eq!(truncate_content("", 10), "");
397 }
398
399 #[tokio::test]
400 async fn clear_rejects_append_only_markdown_backend() {
401 let tmp = TempDir::new().unwrap();
402 let mut config = Config::default();
403 config.data_dir = tmp.path().to_path_buf();
404 config.memory.backend = "markdown".into();
405
406 let err = handle_command(
407 crate::MemoryCommands::Clear {
408 key: None,
409 category: None,
410 yes: true,
411 },
412 &config,
413 )
414 .await
415 .unwrap_err();
416
417 let msg = err.to_string();
418 assert!(msg.contains("append-only backend 'markdown'"));
419 assert!(msg.contains("switch to a deletable backend"));
420 }
421
422 #[tokio::test]
423 async fn clear_rejects_qdrant_backend_constructed_as_markdown() {
424 let tmp = TempDir::new().unwrap();
425 let mut config = Config::default();
426 config.data_dir = tmp.path().to_path_buf();
427 config.memory.backend = "qdrant".into();
428
429 let err = handle_command(
430 crate::MemoryCommands::Clear {
431 key: None,
432 category: None,
433 yes: true,
434 },
435 &config,
436 )
437 .await
438 .unwrap_err();
439
440 let msg = err.to_string();
441 assert!(msg.contains("append-only backend 'qdrant'"));
442 assert!(!msg.contains("or qdrant"));
443 }
444
445 #[tokio::test]
446 async fn clear_rejects_dotted_qdrant_backend() {
447 let tmp = TempDir::new().unwrap();
448 let mut config = Config::default();
449 config.data_dir = tmp.path().to_path_buf();
450 config.memory.backend = "qdrant.default".into();
451
452 let err = handle_command(
453 crate::MemoryCommands::Clear {
454 key: None,
455 category: None,
456 yes: true,
457 },
458 &config,
459 )
460 .await
461 .unwrap_err();
462
463 let msg = err.to_string();
464 assert!(msg.contains("append-only backend 'qdrant'"));
465 assert!(!msg.contains("or qdrant"));
466 }
467}