Skip to main content

zeroclaw/memory/
cli.rs

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/// Handle `zeroclaw memory <subcommand>` CLI commands.
13pub 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
30/// Create a memory backend with the configured embedder wired in.
31///
32/// Unlike `create_cli_memory`, which skips embedding setup for pure
33/// read/delete operations, this factory is used by commands that must
34/// actually compute embeddings (e.g. `reindex`). Mirrors the gateway's
35/// memory construction so the same model provider / route resolution
36/// applies. Removed `model_providers.fallback`; the embedder API key falls
37/// back to the first configured model provider, matching how the gateway
38/// resolves it (`crates/zeroclaw-gateway/src/lib.rs` `fallback`).
39fn 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
75/// Create a lightweight memory backend for CLI management operations.
76///
77/// CLI commands (list/get/stats/clear) never use vector search, so we skip
78/// embedding model_provider initialisation for local backends by using the
79/// migration factory.
80fn 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    // Try exact match first.
141    if let Some(entry) = mem.get(key).await? {
142        print_entry(&entry);
143        return Ok(());
144    }
145
146    // Fall back to prefix match so users can copy partial keys from `list`.
147    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    // Single-key deletion (exact or prefix match).
247    if let Some(key) = key {
248        return handle_clear_key(&*mem, &key, yes).await;
249    }
250
251    // Batch deletion by category (or all).
252    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
290/// Delete a single entry by exact key or prefix match.
291async fn handle_clear_key(mem: &dyn Memory, key: &str, yes: bool) -> Result<()> {
292    // Resolve the target key (exact match or unique prefix).
293    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}