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/// Resolve a `cli-*` Fluent key for memory CLI output. Under `agent-runtime`
13/// (default, and what CI/release build) this routes through Fluent; without it
14/// the runtime i18n crate is absent, so the English `fallback` is used.
15#[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() // i18n-exempt: English fallback when Fluent (agent-runtime) is disabled
24    }
25}
26
27/// `mt` with `{$name}` arguments.
28#[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() // i18n-exempt: English fallback when Fluent (agent-runtime) is disabled
37    }
38}
39
40/// Handle `zeroclaw memory <subcommand>` CLI commands.
41pub 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
58/// Create a memory backend with the configured embedder wired in.
59///
60/// Unlike `create_cli_memory`, which skips embedding setup for pure
61/// read/delete operations, this factory is used by commands that must
62/// actually compute embeddings (e.g. `reindex`). Mirrors the gateway's
63/// memory construction so the same model provider / route resolution
64/// applies. Removed `model_providers.fallback`; the embedder API key falls
65/// back to the first configured model provider, matching how the gateway
66/// resolves it (`crates/zeroclaw-gateway/src/lib.rs` `fallback`).
67fn 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
104/// Create a lightweight memory backend for CLI management operations.
105///
106/// CLI commands (list/get/stats/clear) never use vector search, so we skip
107/// embedding model_provider initialisation for local backends by using the
108/// migration factory.
109fn 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    // Try exact match first.
187    if let Some(entry) = mem.get(key).await? {
188        print_entry(&entry);
189        return Ok(());
190    }
191
192    // Fall back to prefix match so users can copy partial keys from `list`.
193    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    // Single-key deletion (exact or prefix match).
351    if let Some(key) = key {
352        return handle_clear_key(&*mem, &key, yes).await;
353    }
354
355    // Batch deletion by category (or all).
356    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
401/// Delete a single entry by exact key or prefix match.
402async fn handle_clear_key(mem: &dyn Memory, key: &str, yes: bool) -> Result<()> {
403    // Resolve the target key (exact match or unique prefix).
404    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        // The backend name is interpolated verbatim into the (localized) error,
554        // so assert on the locale-stable name rather than the translated prose.
555        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}