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
12#[allow(unused_variables)]
16fn mt(key: &str, fallback: &str) -> String {
17 #[cfg(feature = "agent-runtime")]
18 {
19 i18n::get_required_cli_string(key)
20 }
21 #[cfg(not(feature = "agent-runtime"))]
22 {
23 fallback.to_string() }
25}
26
27#[allow(unused_variables)]
29fn mt_args(key: &str, args: &[(&str, &str)], fallback: &str) -> String {
30 #[cfg(feature = "agent-runtime")]
31 {
32 i18n::get_required_cli_string_with_args(key, args)
33 }
34 #[cfg(not(feature = "agent-runtime"))]
35 {
36 fallback.to_string() }
38}
39
40pub async fn handle_command(command: crate::MemoryCommands, config: &Config) -> Result<()> {
42 match command {
43 crate::MemoryCommands::List {
44 category,
45 session,
46 limit,
47 offset,
48 } => handle_list(config, category, session, limit, offset).await,
49 crate::MemoryCommands::Get { key } => handle_get(config, &key).await,
50 crate::MemoryCommands::Stats => handle_stats(config).await,
51 crate::MemoryCommands::Clear { key, category, yes } => {
52 handle_clear(config, key, category, yes).await
53 }
54 crate::MemoryCommands::Reindex => handle_reindex(config).await,
55 }
56}
57
58fn create_memory_with_embedder(config: &Config) -> Result<Box<dyn Memory>> {
68 let backend = backend_kind_from_dotted(&config.memory.backend);
69 if matches!(classify_memory_backend(&backend), MemoryBackendKind::None) {
70 bail!("Memory backend is 'none' (disabled). No entries to manage.");
71 }
72 create_memory_with_storage_and_routes(
73 &config.memory,
74 &config.embedding_routes,
75 config.resolve_active_storage(),
76 &config.data_dir,
77 None,
78 )
79}
80
81async fn handle_reindex(config: &Config) -> Result<()> {
82 let mem = create_memory_with_embedder(config)?;
83 println!(
84 "{} {}",
85 style("→").cyan(),
86 mt("cli-memory-reindexing", "Reindexing memory backend...")
87 );
88 let count = mem.reindex().await?;
89 if count == 0 {
90 println!(
91 "{} FTS rebuilt. No embeddings to fill in (either everything is already embedded or the backend has no embedder configured).",
92 style("✓").green()
93 );
94 } else {
95 println!(
96 "{} FTS rebuilt. Re-embedded {count} {}.",
97 style("✓").green(),
98 if count == 1 { "entry" } else { "entries" }
99 );
100 }
101 Ok(())
102}
103
104fn create_cli_memory(config: &Config) -> Result<Box<dyn Memory>> {
110 let backend = backend_kind_from_dotted(&config.memory.backend);
111
112 match classify_memory_backend(&backend) {
113 MemoryBackendKind::None => {
114 bail!("Memory backend is 'none' (disabled). No entries to manage.");
115 }
116 _ => create_memory_for_migration(&backend, &config.data_dir),
117 }
118}
119
120async fn handle_list(
121 config: &Config,
122 category: Option<String>,
123 session: Option<String>,
124 limit: usize,
125 offset: usize,
126) -> Result<()> {
127 let mem = create_cli_memory(config)?;
128 let cat = category.as_deref().map(parse_category);
129 let entries = mem.list(cat.as_ref(), session.as_deref()).await?;
130
131 if entries.is_empty() {
132 println!("{}", mt("cli-memory-none", "No memory entries found."));
133 return Ok(());
134 }
135
136 let total = entries.len();
137 let page: Vec<_> = entries.into_iter().skip(offset).take(limit).collect();
138
139 if page.is_empty() {
140 println!(
141 "{}",
142 mt_args(
143 "cli-memory-none-at-offset",
144 &[
145 ("offset", &offset.to_string()),
146 ("total", &total.to_string())
147 ],
148 "No entries at offset"
149 )
150 );
151 return Ok(());
152 }
153
154 println!(
155 "Memory entries ({total} total, showing {}-{}):\n",
156 offset + 1,
157 offset + page.len(),
158 );
159
160 for entry in &page {
161 println!(
162 "- {} [{}]",
163 style(&entry.key).white().bold(),
164 entry.category,
165 );
166 println!(" {}", truncate_content(&entry.content, 80));
167 }
168
169 if offset + page.len() < total {
170 println!(
171 "\n{}",
172 mt_args(
173 "cli-memory-next-page",
174 &[("offset", &(offset + limit).to_string())],
175 "Use --offset to see the next page"
176 )
177 );
178 }
179
180 Ok(())
181}
182
183async fn handle_get(config: &Config, key: &str) -> Result<()> {
184 let mem = create_cli_memory(config)?;
185
186 if let Some(entry) = mem.get(key).await? {
188 print_entry(&entry);
189 return Ok(());
190 }
191
192 let all = mem.list(None, None).await?;
194 let matches: Vec<_> = all.iter().filter(|e| e.key.starts_with(key)).collect();
195
196 match matches.len() {
197 0 => println!(
198 "{}",
199 mt_args(
200 "cli-memory-key-not-found",
201 &[("key", key)],
202 "No memory entry found for key"
203 )
204 ),
205 1 => print_entry(matches[0]),
206 n => {
207 println!(
208 "{}\n",
209 mt_args(
210 "cli-memory-prefix-matched",
211 &[("key", key), ("n", &n.to_string())],
212 "Prefix matched entries"
213 )
214 );
215 for entry in matches {
216 println!(
217 "- {} [{}]",
218 style(&entry.key).white().bold(),
219 entry.category
220 );
221 }
222 println!(
223 "\n{}",
224 mt(
225 "cli-memory-narrow-prefix",
226 "Specify a longer prefix to narrow the match."
227 )
228 );
229 }
230 }
231
232 Ok(())
233}
234
235fn print_entry(entry: &super::traits::MemoryEntry) {
236 println!(
237 "{}",
238 mt_args(
239 "cli-memory-key",
240 &[("value", &style(&entry.key).white().bold().to_string())],
241 "Key"
242 )
243 );
244 println!(
245 "{}",
246 mt_args(
247 "cli-memory-category",
248 &[("value", &entry.category.to_string())],
249 "Category"
250 )
251 );
252 println!(
253 "{}",
254 mt_args(
255 "cli-memory-timestamp",
256 &[("value", &entry.timestamp.to_string())],
257 "Timestamp"
258 )
259 );
260 if let Some(sid) = &entry.session_id {
261 println!(
262 "{}",
263 mt_args("cli-memory-session", &[("value", sid)], "Session")
264 );
265 }
266 println!("\n{}", entry.content);
267}
268
269async fn handle_stats(config: &Config) -> Result<()> {
270 let mem = create_cli_memory(config)?;
271 let healthy = mem.health_check().await;
272 let total = mem.count().await.unwrap_or(0);
273
274 println!("{}\n", mt("cli-memory-stats-header", "Memory Statistics:"));
275 println!(
276 "{}",
277 mt_args(
278 "cli-memory-backend",
279 &[("value", &style(mem.name()).white().bold().to_string())],
280 "Backend"
281 )
282 );
283 println!(
284 " Health: {}",
285 if healthy {
286 style("healthy").green().bold().to_string()
287 } else {
288 style("unhealthy").yellow().bold().to_string()
289 }
290 );
291 println!(
292 "{}",
293 mt_args(
294 "cli-memory-total",
295 &[("value", &total.to_string())],
296 "Total"
297 )
298 );
299
300 let all = mem.list(None, None).await.unwrap_or_default();
301 if !all.is_empty() {
302 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
303 for entry in &all {
304 *counts.entry(entry.category.to_string()).or_default() += 1;
305 }
306
307 println!("\n{}", mt("cli-memory-by-category", " By category:"));
308 let mut sorted: Vec<_> = counts.into_iter().collect();
309 sorted.sort_by_key(|entry| std::cmp::Reverse(entry.1));
310 for (cat, count) in sorted {
311 println!(" {cat:<20} {count}");
312 }
313 }
314
315 Ok(())
316}
317
318fn unsupported_clear_backend_message(backend: &str) -> String {
319 #[cfg(feature = "agent-runtime")]
320 {
321 i18n::get_required_cli_string_with_args(
322 "cli-memory-clear-unsupported-backend",
323 &[("backend", backend)],
324 )
325 }
326
327 #[cfg(not(feature = "agent-runtime"))]
328 {
329 format!(
330 "memory clear is unsupported for append-only backend '{backend}'; switch to a deletable backend (sqlite, lucid, or postgres)"
331 )
332 }
333}
334
335async fn handle_clear(
336 config: &Config,
337 key: Option<String>,
338 category: Option<String>,
339 yes: bool,
340) -> Result<()> {
341 let backend = backend_kind_from_dotted(&config.memory.backend);
342 if matches!(
343 classify_memory_backend(&backend),
344 MemoryBackendKind::Markdown | MemoryBackendKind::Qdrant
345 ) {
346 bail!(unsupported_clear_backend_message(&backend));
347 }
348 let mem = create_cli_memory(config)?;
349
350 if let Some(key) = key {
352 return handle_clear_key(&*mem, &key, yes).await;
353 }
354
355 let cat = category.as_deref().map(parse_category);
357 let entries = mem.list(cat.as_ref(), None).await?;
358
359 if entries.is_empty() {
360 println!("{}", mt("cli-memory-none-to-clear", "No entries to clear."));
361 return Ok(());
362 }
363
364 let scope = category.as_deref().unwrap_or("all categories");
365 println!(
366 "{}",
367 mt_args(
368 "cli-memory-found-in-scope",
369 &[("count", &entries.len().to_string()), ("scope", scope)],
370 "Found entries"
371 )
372 );
373
374 if !yes {
375 let confirmed = dialoguer::Confirm::new()
376 .with_prompt(format!(" Delete {} entries?", entries.len()))
377 .default(false)
378 .interact()?;
379 if !confirmed {
380 println!("{}", mt("cli-memory-aborted", "Aborted."));
381 return Ok(());
382 }
383 }
384
385 let mut deleted = 0usize;
386 for entry in &entries {
387 if mem.forget(&entry.key).await? {
388 deleted += 1;
389 }
390 }
391
392 println!(
393 "{} Cleared {deleted}/{} entries.",
394 style("✓").green().bold(),
395 entries.len(),
396 );
397
398 Ok(())
399}
400
401async fn handle_clear_key(mem: &dyn Memory, key: &str, yes: bool) -> Result<()> {
403 let target = if mem.get(key).await?.is_some() {
405 key.to_string()
406 } else {
407 let all = mem.list(None, None).await?;
408 let matches: Vec<_> = all.iter().filter(|e| e.key.starts_with(key)).collect();
409 match matches.len() {
410 0 => {
411 println!(
412 "{}",
413 mt_args(
414 "cli-memory-key-not-found",
415 &[("key", key)],
416 "No memory entry found for key"
417 )
418 );
419 return Ok(());
420 }
421 1 => matches[0].key.clone(),
422 n => {
423 println!(
424 "{}\n",
425 mt_args(
426 "cli-memory-prefix-matched",
427 &[("key", key), ("n", &n.to_string())],
428 "Prefix matched entries"
429 )
430 );
431 for entry in matches {
432 println!(
433 "- {} [{}]",
434 style(&entry.key).white().bold(),
435 entry.category
436 );
437 }
438 println!(
439 "\n{}",
440 mt(
441 "cli-memory-narrow-prefix",
442 "Specify a longer prefix to narrow the match."
443 )
444 );
445 return Ok(());
446 }
447 }
448 };
449
450 if !yes {
451 let confirmed = dialoguer::Confirm::new()
452 .with_prompt(format!(" Delete '{target}'?"))
453 .default(false)
454 .interact()?;
455 if !confirmed {
456 println!("{}", mt("cli-memory-aborted", "Aborted."));
457 return Ok(());
458 }
459 }
460
461 if mem.forget(&target).await? {
462 println!(
463 "{} {}",
464 style("✓").green().bold(),
465 mt_args("cli-memory-deleted-key", &[("key", &target)], "Deleted key")
466 );
467 }
468
469 Ok(())
470}
471
472fn parse_category(s: &str) -> MemoryCategory {
473 match s.trim().to_ascii_lowercase().as_str() {
474 "core" => MemoryCategory::Core,
475 "daily" => MemoryCategory::Daily,
476 "conversation" => MemoryCategory::Conversation,
477 other => MemoryCategory::Custom(other.to_string()),
478 }
479}
480
481fn truncate_content(s: &str, max_len: usize) -> String {
482 let line = s.lines().next().unwrap_or(s);
483 if line.len() <= max_len {
484 return line.to_string();
485 }
486 let truncated: String = line.chars().take(max_len.saturating_sub(3)).collect();
487 format!("{truncated}...")
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use tempfile::TempDir;
494
495 #[test]
496 fn parse_category_known_variants() {
497 assert_eq!(parse_category("core"), MemoryCategory::Core);
498 assert_eq!(parse_category("daily"), MemoryCategory::Daily);
499 assert_eq!(parse_category("conversation"), MemoryCategory::Conversation);
500 assert_eq!(parse_category("CORE"), MemoryCategory::Core);
501 assert_eq!(parse_category(" Daily "), MemoryCategory::Daily);
502 }
503
504 #[test]
505 fn parse_category_custom_fallback() {
506 assert_eq!(
507 parse_category("project_notes"),
508 MemoryCategory::Custom("project_notes".into())
509 );
510 }
511
512 #[test]
513 fn truncate_content_short_text_unchanged() {
514 assert_eq!(truncate_content("hello", 10), "hello");
515 }
516
517 #[test]
518 fn truncate_content_long_text_truncated() {
519 let result = truncate_content("this is a very long string", 10);
520 assert!(result.ends_with("..."));
521 assert!(result.chars().count() <= 10);
522 }
523
524 #[test]
525 fn truncate_content_multiline_uses_first_line() {
526 assert_eq!(truncate_content("first\nsecond", 20), "first");
527 }
528
529 #[test]
530 fn truncate_content_empty_string() {
531 assert_eq!(truncate_content("", 10), "");
532 }
533
534 #[tokio::test]
535 async fn clear_rejects_append_only_markdown_backend() {
536 let tmp = TempDir::new().unwrap();
537 let mut config = Config::default();
538 config.data_dir = tmp.path().to_path_buf();
539 config.memory.backend = "markdown".into();
540
541 let err = handle_command(
542 crate::MemoryCommands::Clear {
543 key: None,
544 category: None,
545 yes: true,
546 },
547 &config,
548 )
549 .await
550 .unwrap_err();
551
552 let msg = err.to_string();
553 assert!(msg.contains("'markdown'"), "got: {msg}");
556 }
557
558 #[tokio::test]
559 async fn clear_rejects_qdrant_backend_constructed_as_markdown() {
560 let tmp = TempDir::new().unwrap();
561 let mut config = Config::default();
562 config.data_dir = tmp.path().to_path_buf();
563 config.memory.backend = "qdrant".into();
564
565 let err = handle_command(
566 crate::MemoryCommands::Clear {
567 key: None,
568 category: None,
569 yes: true,
570 },
571 &config,
572 )
573 .await
574 .unwrap_err();
575
576 let msg = err.to_string();
577 assert!(msg.contains("'qdrant'"), "got: {msg}");
578 }
579
580 #[tokio::test]
581 async fn clear_rejects_dotted_qdrant_backend() {
582 let tmp = TempDir::new().unwrap();
583 let mut config = Config::default();
584 config.data_dir = tmp.path().to_path_buf();
585 config.memory.backend = "qdrant.default".into();
586
587 let err = handle_command(
588 crate::MemoryCommands::Clear {
589 key: None,
590 category: None,
591 yes: true,
592 },
593 &config,
594 )
595 .await
596 .unwrap_err();
597
598 let msg = err.to_string();
599 assert!(msg.contains("'qdrant'"), "got: {msg}");
600 }
601}