Skip to main content

construct/memory/
cli.rs

1//! `construct memory` CLI commands, backed by Kumiho MCP.
2//!
3//! Persistent memory in Construct is stored in Kumiho as item → revision pairs,
4//! with small content stashed in the revision's metadata (`content` key). The
5//! CLI targets a dedicated `Memory` space under the configured memory project
6//! so CLI-managed entries stay separate from memories captured via
7//! `kumiho_memory_reflect` and friends.
8
9use crate::config::Config;
10use crate::gateway::kumiho_client::{
11    ItemResponse, KumihoClient, KumihoError, RevisionResponse, build_client_from_config, slugify,
12};
13use anyhow::{Result, bail};
14use std::collections::HashMap;
15use std::io::{self, Write};
16
17/// Subspace name (under the configured `kumiho.memory_project`) where CLI-managed
18/// entries live. Keeps them separate from agent-captured memories.
19pub const CLI_SPACE_NAME: &str = "Memory";
20const MEMORY_ITEM_KIND: &str = "memory";
21const CONTENT_PREVIEW_LEN: usize = 120;
22
23/// Handle `construct memory <subcommand>` CLI commands.
24pub async fn handle_command(command: crate::MemoryCommands, config: &Config) -> Result<()> {
25    let ctx = CliContext::from_config(config);
26
27    match command {
28        crate::MemoryCommands::List {
29            category,
30            session,
31            limit,
32            offset,
33        } => list_entries(&ctx, category.as_deref(), session.as_deref(), limit, offset).await,
34        crate::MemoryCommands::Get { key } => get_entry(&ctx, &key).await,
35        crate::MemoryCommands::Stats => show_stats(&ctx).await,
36        crate::MemoryCommands::Clear { key, category, yes } => {
37            clear_entries(&ctx, key.as_deref(), category.as_deref(), yes).await
38        }
39    }
40}
41
42struct CliContext {
43    client: KumihoClient,
44    project: String,
45    api_url: String,
46}
47
48impl CliContext {
49    fn from_config(config: &Config) -> Self {
50        Self {
51            client: build_client_from_config(config),
52            project: config.kumiho.memory_project.clone(),
53            api_url: config.kumiho.api_url.clone(),
54        }
55    }
56
57    fn space_path(&self) -> String {
58        format!("/{}/{CLI_SPACE_NAME}", self.project)
59    }
60
61    async fn ensure_space(&self) -> Result<()> {
62        self.client
63            .ensure_project(&self.project)
64            .await
65            .map_err(|e| kumiho_err(e, "ensure project"))?;
66        self.client
67            .ensure_child_space(&self.project, &format!("/{}", self.project), CLI_SPACE_NAME)
68            .await
69            .map_err(|e| kumiho_err(e, "ensure space"))?;
70        Ok(())
71    }
72}
73
74async fn list_entries(
75    ctx: &CliContext,
76    category_filter: Option<&str>,
77    session_filter: Option<&str>,
78    limit: usize,
79    offset: usize,
80) -> Result<()> {
81    ctx.ensure_space().await?;
82    let space = ctx.space_path();
83
84    // Fetch a superset so we can filter client-side, capped sensibly.
85    let fetch_limit =
86        u32::try_from(limit.saturating_add(offset).saturating_mul(2).max(50)).unwrap_or(u32::MAX);
87    let items = ctx
88        .client
89        .list_items_paged(&space, false, fetch_limit, 0)
90        .await
91        .map_err(|e| kumiho_err(e, "list items"))?;
92
93    if items.is_empty() {
94        println!("No memory entries in {space}.");
95        return Ok(());
96    }
97
98    let mut rows: Vec<(ItemResponse, RevisionResponse)> = Vec::with_capacity(items.len());
99    for item in items {
100        match ctx.client.get_latest_revision(&item.kref).await {
101            Ok(rev) => rows.push((item, rev)),
102            Err(KumihoError::Api { status: 404, .. }) => continue,
103            Err(e) => return Err(kumiho_err(e, "fetch revision")),
104        }
105    }
106
107    let filtered: Vec<_> = rows
108        .into_iter()
109        .filter(|(_, rev)| {
110            category_filter
111                .map(|c| rev.metadata.get("category").map(String::as_str) == Some(c))
112                .unwrap_or(true)
113        })
114        .filter(|(_, rev)| {
115            session_filter
116                .map(|s| rev.metadata.get("session_id").map(String::as_str) == Some(s))
117                .unwrap_or(true)
118        })
119        .skip(offset)
120        .take(limit)
121        .collect();
122
123    if filtered.is_empty() {
124        println!("No memory entries matched the given filters.");
125        return Ok(());
126    }
127
128    for (item, rev) in &filtered {
129        let key = rev
130            .metadata
131            .get("key")
132            .cloned()
133            .unwrap_or_else(|| item.item_name.clone());
134        let category = rev
135            .metadata
136            .get("category")
137            .map(String::as_str)
138            .unwrap_or("core");
139        let content = rev
140            .metadata
141            .get("content")
142            .map(String::as_str)
143            .unwrap_or("");
144        println!(
145            "{key}\t[{category}]\t{}",
146            truncate(content, CONTENT_PREVIEW_LEN)
147        );
148    }
149
150    println!();
151    println!(
152        "{} entr{} shown.",
153        filtered.len(),
154        if filtered.len() == 1 { "y" } else { "ies" }
155    );
156    Ok(())
157}
158
159async fn get_entry(ctx: &CliContext, key: &str) -> Result<()> {
160    ctx.ensure_space().await?;
161    let space = ctx.space_path();
162    let slug = slugify(key);
163    if slug.is_empty() {
164        bail!("Key '{key}' could not be slugified to a valid identifier");
165    }
166
167    let items = ctx
168        .client
169        .list_items_filtered(&space, &slug, false)
170        .await
171        .map_err(|e| kumiho_err(e, "search for entry"))?;
172
173    let item = items
174        .into_iter()
175        .find(|i| i.item_name == slug)
176        .ok_or_else(|| anyhow::anyhow!("No memory entry found for key '{key}' (slug: {slug})"))?;
177
178    let rev = ctx
179        .client
180        .get_latest_revision(&item.kref)
181        .await
182        .map_err(|e| kumiho_err(e, "fetch revision"))?;
183
184    let content = rev
185        .metadata
186        .get("content")
187        .map(String::as_str)
188        .unwrap_or("");
189    let category = rev
190        .metadata
191        .get("category")
192        .map(String::as_str)
193        .unwrap_or("core");
194    let original_key = rev
195        .metadata
196        .get("key")
197        .cloned()
198        .unwrap_or_else(|| slug.clone());
199
200    println!("Key:      {original_key}");
201    println!("Slug:     {slug}");
202    println!("Category: {category}");
203    if let Some(session) = rev.metadata.get("session_id") {
204        println!("Session:  {session}");
205    }
206    if let Some(origin) = rev.metadata.get("migrated_from") {
207        println!("Origin:   {origin}");
208    }
209    if let Some(imported) = rev.metadata.get("imported_at") {
210        println!("Imported: {imported}");
211    }
212    if let Some(created) = rev.created_at.as_deref() {
213        println!("Created:  {created}");
214    }
215    println!("Kref:     {}", item.kref);
216    println!();
217    println!("{content}");
218
219    Ok(())
220}
221
222async fn show_stats(ctx: &CliContext) -> Result<()> {
223    println!("Kumiho endpoint: {}", ctx.api_url);
224    println!("Memory project: {}", ctx.project);
225
226    let root = format!("/{}", ctx.project);
227    let spaces = match ctx.client.list_spaces(&root, true).await {
228        Ok(spaces) => spaces,
229        Err(KumihoError::Unreachable(err)) => {
230            bail!(
231                "Kumiho service unreachable at {}: {err}. \
232                 Check that Kumiho is running and that `kumiho.api_url` points to it.",
233                ctx.api_url
234            );
235        }
236        Err(e) => return Err(kumiho_err(e, "list spaces")),
237    };
238
239    let mut total_items = 0usize;
240    let mut rows: Vec<(String, usize)> = Vec::new();
241
242    // Include the project root itself.
243    let mut space_paths: Vec<String> = vec![root.clone()];
244    space_paths.extend(spaces.into_iter().map(|s| s.path));
245
246    for path in &space_paths {
247        let count = ctx
248            .client
249            .list_items_paged(path, false, 500, 0)
250            .await
251            .map(|items| items.len())
252            .unwrap_or(0);
253        total_items += count;
254        rows.push((path.clone(), count));
255    }
256
257    println!("Spaces:         {}", space_paths.len());
258    println!("Total items:    {}", total_items);
259    println!();
260    println!("Per-space counts:");
261    for (path, count) in &rows {
262        println!("  {path:<40} {count}");
263    }
264
265    Ok(())
266}
267
268async fn clear_entries(
269    ctx: &CliContext,
270    key_filter: Option<&str>,
271    category_filter: Option<&str>,
272    skip_confirm: bool,
273) -> Result<()> {
274    ctx.ensure_space().await?;
275    let space = ctx.space_path();
276
277    // Collect items to delete, applying filters.
278    let items = ctx
279        .client
280        .list_items_paged(&space, false, 500, 0)
281        .await
282        .map_err(|e| kumiho_err(e, "list items"))?;
283
284    let mut targets: Vec<ItemResponse> = Vec::new();
285    for item in items {
286        if let Some(prefix) = key_filter {
287            let slug_prefix = slugify(prefix);
288            if !item.item_name.starts_with(&slug_prefix) {
289                continue;
290            }
291        }
292        if let Some(cat) = category_filter {
293            let rev = ctx.client.get_latest_revision(&item.kref).await.ok();
294            let matches = rev
295                .as_ref()
296                .and_then(|r| r.metadata.get("category"))
297                .map(String::as_str)
298                == Some(cat);
299            if !matches {
300                continue;
301            }
302        }
303        targets.push(item);
304    }
305
306    if targets.is_empty() {
307        println!("No entries matched the given filter — nothing to clear.");
308        return Ok(());
309    }
310
311    if !skip_confirm {
312        print!(
313            "About to delete {} entr{} from {space}. Continue? [y/N] ",
314            targets.len(),
315            if targets.len() == 1 { "y" } else { "ies" }
316        );
317        io::stdout().flush().ok();
318        let mut input = String::new();
319        io::stdin().read_line(&mut input)?;
320        if !matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
321            println!("Cancelled.");
322            return Ok(());
323        }
324    }
325
326    let mut deleted = 0usize;
327    for item in targets {
328        match ctx.client.delete_item(&item.kref).await {
329            Ok(()) => deleted += 1,
330            Err(e) => {
331                eprintln!("Failed to delete {}: {}", item.item_name, e);
332            }
333        }
334    }
335
336    println!(
337        "Deleted {deleted} entr{}.",
338        if deleted == 1 { "y" } else { "ies" }
339    );
340    Ok(())
341}
342
343fn truncate(s: &str, max_chars: usize) -> String {
344    let single_line: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
345    if single_line.chars().count() <= max_chars {
346        single_line
347    } else {
348        let truncated: String = single_line.chars().take(max_chars).collect();
349        format!("{truncated}…")
350    }
351}
352
353fn kumiho_err(e: KumihoError, action: &'static str) -> anyhow::Error {
354    match e {
355        KumihoError::Unreachable(err) => anyhow::anyhow!(
356            "Kumiho service unreachable while attempting to {action}: {err}. \
357             Check that Kumiho is running and that `kumiho.api_url` points to it."
358        ),
359        KumihoError::Api { status, body } => {
360            anyhow::anyhow!("Kumiho returned {status} while attempting to {action}: {body}")
361        }
362        KumihoError::Decode(msg) => anyhow::anyhow!(
363            "Kumiho returned an unexpected response while attempting to {action}: {msg}"
364        ),
365    }
366}
367
368/// Construct a revision metadata map for a CLI-stored memory entry.
369///
370/// Kept as a free helper so other callers (e.g. future `construct memory store`
371/// subcommand) can build revisions with the same schema the list/get commands
372/// expect.
373#[allow(dead_code)]
374pub fn cli_revision_metadata(
375    key: &str,
376    content: &str,
377    category: &str,
378    session_id: Option<&str>,
379) -> HashMap<String, String> {
380    let mut meta = HashMap::new();
381    meta.insert("key".into(), key.to_string());
382    meta.insert("content".into(), content.to_string());
383    meta.insert("category".into(), category.to_string());
384    if let Some(session) = session_id {
385        meta.insert("session_id".into(), session.to_string());
386    }
387    meta.insert("kind".into(), MEMORY_ITEM_KIND.to_string());
388    meta
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn truncate_shortens_long_strings() {
397        let long = "a".repeat(200);
398        let t = truncate(&long, 50);
399        assert_eq!(t.chars().count(), 51); // 50 chars + ellipsis
400        assert!(t.ends_with('…'));
401    }
402
403    #[test]
404    fn truncate_passes_through_short_strings() {
405        let t = truncate("short", 50);
406        assert_eq!(t, "short");
407    }
408
409    #[test]
410    fn truncate_collapses_whitespace() {
411        let t = truncate("a  b\nc\td", 50);
412        assert_eq!(t, "a b c d");
413    }
414
415    #[test]
416    fn cli_revision_metadata_contains_expected_keys() {
417        let meta = cli_revision_metadata("my_key", "hello", "core", Some("sess-1"));
418        assert_eq!(meta.get("key").map(String::as_str), Some("my_key"));
419        assert_eq!(meta.get("content").map(String::as_str), Some("hello"));
420        assert_eq!(meta.get("category").map(String::as_str), Some("core"));
421        assert_eq!(meta.get("session_id").map(String::as_str), Some("sess-1"));
422        assert_eq!(meta.get("kind").map(String::as_str), Some(MEMORY_ITEM_KIND));
423    }
424
425    #[test]
426    fn cli_revision_metadata_omits_session_when_absent() {
427        let meta = cli_revision_metadata("k", "v", "daily", None);
428        assert!(!meta.contains_key("session_id"));
429    }
430}