Skip to main content

agentic_memory/cli/
commands.rs

1//! CLI command implementations.
2
3use std::path::Path;
4
5use crate::engine::{
6    CausalParams, PatternParams, PatternSort, QueryEngine, TraversalParams, WriteEngine,
7};
8use crate::format::{AmemReader, AmemWriter};
9use crate::graph::traversal::TraversalDirection;
10use crate::graph::MemoryGraph;
11use crate::types::{AmemResult, CognitiveEvent, CognitiveEventBuilder, Edge, EdgeType, EventType};
12
13/// Create a new empty .amem file.
14pub fn cmd_create(path: &Path, dimension: usize) -> AmemResult<()> {
15    let graph = MemoryGraph::new(dimension);
16    let writer = AmemWriter::new(dimension);
17    writer.write_to_file(&graph, path)?;
18    println!("Created {}", path.display());
19    Ok(())
20}
21
22/// Display information about an .amem file.
23pub fn cmd_info(path: &Path, json: bool) -> AmemResult<()> {
24    let graph = AmemReader::read_from_file(path)?;
25    let file_size = std::fs::metadata(path)?.len();
26
27    if json {
28        let info = serde_json::json!({
29            "file": path.display().to_string(),
30            "version": 1,
31            "dimension": graph.dimension(),
32            "nodes": graph.node_count(),
33            "edges": graph.edge_count(),
34            "sessions": graph.session_index().session_count(),
35            "file_size": file_size,
36            "node_types": {
37                "facts": graph.type_index().count(EventType::Fact),
38                "decisions": graph.type_index().count(EventType::Decision),
39                "inferences": graph.type_index().count(EventType::Inference),
40                "corrections": graph.type_index().count(EventType::Correction),
41                "skills": graph.type_index().count(EventType::Skill),
42                "episodes": graph.type_index().count(EventType::Episode),
43            }
44        });
45        println!(
46            "{}",
47            serde_json::to_string_pretty(&info).unwrap_or_default()
48        );
49    } else {
50        println!("File: {}", path.display());
51        println!("Version: 1");
52        println!("Dimension: {}", graph.dimension());
53        println!("Nodes: {}", graph.node_count());
54        println!("Edges: {}", graph.edge_count());
55        println!("Sessions: {}", graph.session_index().session_count());
56        println!("File size: {}", format_size(file_size));
57        println!("Node types:");
58        println!("  Facts: {}", graph.type_index().count(EventType::Fact));
59        println!(
60            "  Decisions: {}",
61            graph.type_index().count(EventType::Decision)
62        );
63        println!(
64            "  Inferences: {}",
65            graph.type_index().count(EventType::Inference)
66        );
67        println!(
68            "  Corrections: {}",
69            graph.type_index().count(EventType::Correction)
70        );
71        println!("  Skills: {}", graph.type_index().count(EventType::Skill));
72        println!(
73            "  Episodes: {}",
74            graph.type_index().count(EventType::Episode)
75        );
76    }
77    Ok(())
78}
79
80/// Add a cognitive event to the graph.
81pub fn cmd_add(
82    path: &Path,
83    event_type: EventType,
84    content: &str,
85    session_id: u32,
86    confidence: f32,
87    supersedes: Option<u64>,
88    json: bool,
89) -> AmemResult<()> {
90    let mut graph = AmemReader::read_from_file(path)?;
91    let write_engine = WriteEngine::new(graph.dimension());
92
93    let id = if let Some(old_id) = supersedes {
94        write_engine.correct(&mut graph, old_id, content, session_id)?
95    } else {
96        let event = CognitiveEventBuilder::new(event_type, content)
97            .session_id(session_id)
98            .confidence(confidence)
99            .build();
100        graph.add_node(event)?
101    };
102
103    let writer = AmemWriter::new(graph.dimension());
104    writer.write_to_file(&graph, path)?;
105
106    if json {
107        println!(
108            "{}",
109            serde_json::json!({"id": id, "type": event_type.name()})
110        );
111    } else {
112        println!(
113            "Added node {} ({}) to {}",
114            id,
115            event_type.name(),
116            path.display()
117        );
118    }
119    Ok(())
120}
121
122/// Add an edge between two nodes.
123pub fn cmd_link(
124    path: &Path,
125    source_id: u64,
126    target_id: u64,
127    edge_type: EdgeType,
128    weight: f32,
129    json: bool,
130) -> AmemResult<()> {
131    let mut graph = AmemReader::read_from_file(path)?;
132    let edge = Edge::new(source_id, target_id, edge_type, weight);
133    graph.add_edge(edge)?;
134
135    let writer = AmemWriter::new(graph.dimension());
136    writer.write_to_file(&graph, path)?;
137
138    if json {
139        println!(
140            "{}",
141            serde_json::json!({"source": source_id, "target": target_id, "type": edge_type.name()})
142        );
143    } else {
144        println!(
145            "Linked {} --{}--> {}",
146            source_id,
147            edge_type.name(),
148            target_id
149        );
150    }
151    Ok(())
152}
153
154/// Get a specific node by ID.
155pub fn cmd_get(path: &Path, node_id: u64, json: bool) -> AmemResult<()> {
156    let graph = AmemReader::read_from_file(path)?;
157    let node = graph
158        .get_node(node_id)
159        .ok_or(crate::types::AmemError::NodeNotFound(node_id))?;
160
161    let edges_out = graph.edges_from(node_id).len();
162    let edges_in = graph.edges_to(node_id).len();
163
164    if json {
165        let info = serde_json::json!({
166            "id": node.id,
167            "type": node.event_type.name(),
168            "created_at": node.created_at,
169            "session_id": node.session_id,
170            "confidence": node.confidence,
171            "access_count": node.access_count,
172            "decay_score": node.decay_score,
173            "content": node.content,
174            "edges_out": edges_out,
175            "edges_in": edges_in,
176        });
177        println!(
178            "{}",
179            serde_json::to_string_pretty(&info).unwrap_or_default()
180        );
181    } else {
182        println!("Node {}", node.id);
183        println!("  Type: {}", node.event_type.name());
184        println!("  Created: {}", format_timestamp(node.created_at));
185        println!("  Session: {}", node.session_id);
186        println!("  Confidence: {:.2}", node.confidence);
187        println!("  Access count: {}", node.access_count);
188        println!("  Decay score: {:.2}", node.decay_score);
189        println!("  Content: {:?}", node.content);
190        println!("  Edges out: {}", edges_out);
191        println!("  Edges in: {}", edges_in);
192    }
193    Ok(())
194}
195
196/// Run a traversal query.
197#[allow(clippy::too_many_arguments)]
198pub fn cmd_traverse(
199    path: &Path,
200    start_id: u64,
201    edge_types: Vec<EdgeType>,
202    direction: TraversalDirection,
203    max_depth: u32,
204    max_results: usize,
205    min_confidence: f32,
206    json: bool,
207) -> AmemResult<()> {
208    let graph = AmemReader::read_from_file(path)?;
209    let query_engine = QueryEngine::new();
210
211    let et = if edge_types.is_empty() {
212        vec![
213            EdgeType::CausedBy,
214            EdgeType::Supports,
215            EdgeType::Contradicts,
216            EdgeType::Supersedes,
217            EdgeType::RelatedTo,
218            EdgeType::PartOf,
219            EdgeType::TemporalNext,
220        ]
221    } else {
222        edge_types
223    };
224
225    let result = query_engine.traverse(
226        &graph,
227        TraversalParams {
228            start_id,
229            edge_types: et,
230            direction,
231            max_depth,
232            max_results,
233            min_confidence,
234        },
235    )?;
236
237    if json {
238        let nodes_info: Vec<serde_json::Value> = result
239            .visited
240            .iter()
241            .map(|&id| {
242                let depth = result.depths.get(&id).copied().unwrap_or(0);
243                if let Some(node) = graph.get_node(id) {
244                    serde_json::json!({
245                        "id": id,
246                        "depth": depth,
247                        "type": node.event_type.name(),
248                        "content": node.content,
249                    })
250                } else {
251                    serde_json::json!({"id": id, "depth": depth})
252                }
253            })
254            .collect();
255        println!(
256            "{}",
257            serde_json::to_string_pretty(&nodes_info).unwrap_or_default()
258        );
259    } else {
260        for &id in &result.visited {
261            let depth = result.depths.get(&id).copied().unwrap_or(0);
262            let indent = "  ".repeat(depth as usize);
263            if let Some(node) = graph.get_node(id) {
264                println!(
265                    "{}[depth {}] Node {} ({}): {:?}",
266                    indent,
267                    depth,
268                    id,
269                    node.event_type.name(),
270                    node.content
271                );
272            }
273        }
274    }
275    Ok(())
276}
277
278/// Pattern search.
279#[allow(clippy::too_many_arguments)]
280pub fn cmd_search(
281    path: &Path,
282    event_types: Vec<EventType>,
283    session_ids: Vec<u32>,
284    min_confidence: Option<f32>,
285    max_confidence: Option<f32>,
286    created_after: Option<u64>,
287    created_before: Option<u64>,
288    sort_by: PatternSort,
289    limit: usize,
290    json: bool,
291) -> AmemResult<()> {
292    let graph = AmemReader::read_from_file(path)?;
293    let query_engine = QueryEngine::new();
294
295    let results = query_engine.pattern(
296        &graph,
297        PatternParams {
298            event_types,
299            min_confidence,
300            max_confidence,
301            session_ids,
302            created_after,
303            created_before,
304            min_decay_score: None,
305            max_results: limit,
306            sort_by,
307        },
308    )?;
309
310    if json {
311        let nodes: Vec<serde_json::Value> = results
312            .iter()
313            .map(|node| {
314                serde_json::json!({
315                    "id": node.id,
316                    "type": node.event_type.name(),
317                    "confidence": node.confidence,
318                    "content": node.content,
319                    "session_id": node.session_id,
320                })
321            })
322            .collect();
323        println!(
324            "{}",
325            serde_json::to_string_pretty(&nodes).unwrap_or_default()
326        );
327    } else {
328        for node in &results {
329            println!(
330                "Node {} ({}, confidence: {:.2}): {:?}",
331                node.id,
332                node.event_type.name(),
333                node.confidence,
334                node.content
335            );
336        }
337        println!("\n{} results", results.len());
338    }
339    Ok(())
340}
341
342/// Causal impact analysis.
343pub fn cmd_impact(path: &Path, node_id: u64, max_depth: u32, json: bool) -> AmemResult<()> {
344    let graph = AmemReader::read_from_file(path)?;
345    let query_engine = QueryEngine::new();
346
347    let result = query_engine.causal(
348        &graph,
349        CausalParams {
350            node_id,
351            max_depth,
352            dependency_types: vec![EdgeType::CausedBy, EdgeType::Supports],
353        },
354    )?;
355
356    if json {
357        let info = serde_json::json!({
358            "root_id": result.root_id,
359            "direct_dependents": result.dependency_tree.get(&node_id).map(|v| v.len()).unwrap_or(0),
360            "total_dependents": result.dependents.len(),
361            "affected_decisions": result.affected_decisions,
362            "affected_inferences": result.affected_inferences,
363            "dependents": result.dependents,
364        });
365        println!(
366            "{}",
367            serde_json::to_string_pretty(&info).unwrap_or_default()
368        );
369    } else {
370        println!("Impact analysis for node {}", node_id);
371        let direct = result
372            .dependency_tree
373            .get(&node_id)
374            .map(|v| v.len())
375            .unwrap_or(0);
376        println!("  Direct dependents: {}", direct);
377        println!("  Total dependents: {}", result.dependents.len());
378        println!("  Affected decisions: {}", result.affected_decisions);
379        println!("  Affected inferences: {}", result.affected_inferences);
380
381        if !result.dependents.is_empty() {
382            println!("\nDependency tree:");
383            print_dependency_tree(&graph, &result.dependency_tree, node_id, 1);
384        }
385    }
386    Ok(())
387}
388
389fn print_dependency_tree(
390    graph: &MemoryGraph,
391    tree: &std::collections::HashMap<u64, Vec<(u64, EdgeType)>>,
392    node_id: u64,
393    depth: usize,
394) {
395    if let Some(deps) = tree.get(&node_id) {
396        for (dep_id, edge_type) in deps {
397            let indent = "  ".repeat(depth);
398            if let Some(node) = graph.get_node(*dep_id) {
399                println!(
400                    "{}<- Node {} ({}, {})",
401                    indent,
402                    dep_id,
403                    node.event_type.name(),
404                    edge_type.name()
405                );
406            }
407            print_dependency_tree(graph, tree, *dep_id, depth + 1);
408        }
409    }
410}
411
412/// Resolve a node through SUPERSEDES chains.
413pub fn cmd_resolve(path: &Path, node_id: u64, json: bool) -> AmemResult<()> {
414    let graph = AmemReader::read_from_file(path)?;
415    let query_engine = QueryEngine::new();
416
417    let resolved = query_engine.resolve(&graph, node_id)?;
418
419    if json {
420        let info = serde_json::json!({
421            "original_id": node_id,
422            "resolved_id": resolved.id,
423            "type": resolved.event_type.name(),
424            "content": resolved.content,
425        });
426        println!(
427            "{}",
428            serde_json::to_string_pretty(&info).unwrap_or_default()
429        );
430    } else {
431        if resolved.id != node_id {
432            // Show chain
433            let mut chain = vec![node_id];
434            let mut current = node_id;
435            for _ in 0..100 {
436                let mut next = None;
437                for edge in graph.edges_to(current) {
438                    if edge.edge_type == EdgeType::Supersedes {
439                        next = Some(edge.source_id);
440                        break;
441                    }
442                }
443                match next {
444                    Some(n) => {
445                        chain.push(n);
446                        current = n;
447                    }
448                    None => break,
449                }
450            }
451            let chain_str: Vec<String> = chain.iter().map(|id| format!("Node {}", id)).collect();
452            println!("{} (current)", chain_str.join(" -> superseded by -> "));
453        } else {
454            println!("Node {} is already the current version", node_id);
455        }
456        println!("\nCurrent version:");
457        println!("  Node {}", resolved.id);
458        println!("  Type: {}", resolved.event_type.name());
459        println!("  Content: {:?}", resolved.content);
460    }
461    Ok(())
462}
463
464/// List sessions.
465pub fn cmd_sessions(path: &Path, limit: usize, json: bool) -> AmemResult<()> {
466    let graph = AmemReader::read_from_file(path)?;
467    let session_ids = graph.session_index().session_ids();
468
469    if json {
470        let sessions: Vec<serde_json::Value> = session_ids
471            .iter()
472            .rev()
473            .take(limit)
474            .map(|&sid| {
475                serde_json::json!({
476                    "session_id": sid,
477                    "node_count": graph.session_index().node_count(sid),
478                })
479            })
480            .collect();
481        println!(
482            "{}",
483            serde_json::to_string_pretty(&sessions).unwrap_or_default()
484        );
485    } else {
486        println!("Sessions in {}:", path.display());
487        for &sid in session_ids.iter().rev().take(limit) {
488            let count = graph.session_index().node_count(sid);
489            println!("  Session {}: {} nodes", sid, count);
490        }
491        println!("  Total: {} sessions", session_ids.len());
492    }
493    Ok(())
494}
495
496/// Export graph as JSON.
497pub fn cmd_export(
498    path: &Path,
499    nodes_only: bool,
500    session: Option<u32>,
501    pretty: bool,
502) -> AmemResult<()> {
503    let graph = AmemReader::read_from_file(path)?;
504
505    let nodes: Vec<&CognitiveEvent> = if let Some(sid) = session {
506        let ids = graph.session_index().get_session(sid);
507        ids.iter().filter_map(|&id| graph.get_node(id)).collect()
508    } else {
509        graph.nodes().iter().collect()
510    };
511
512    let nodes_json: Vec<serde_json::Value> = nodes
513        .iter()
514        .map(|n| {
515            serde_json::json!({
516                "id": n.id,
517                "event_type": n.event_type.name(),
518                "created_at": n.created_at,
519                "session_id": n.session_id,
520                "confidence": n.confidence,
521                "access_count": n.access_count,
522                "last_accessed": n.last_accessed,
523                "decay_score": n.decay_score,
524                "content": n.content,
525            })
526        })
527        .collect();
528
529    let output = if nodes_only {
530        serde_json::json!({"nodes": nodes_json})
531    } else {
532        let edges_json: Vec<serde_json::Value> = graph
533            .edges()
534            .iter()
535            .map(|e| {
536                serde_json::json!({
537                    "source_id": e.source_id,
538                    "target_id": e.target_id,
539                    "edge_type": e.edge_type.name(),
540                    "weight": e.weight,
541                    "created_at": e.created_at,
542                })
543            })
544            .collect();
545        serde_json::json!({"nodes": nodes_json, "edges": edges_json})
546    };
547
548    if pretty {
549        println!(
550            "{}",
551            serde_json::to_string_pretty(&output).unwrap_or_default()
552        );
553    } else {
554        println!("{}", serde_json::to_string(&output).unwrap_or_default());
555    }
556    Ok(())
557}
558
559/// Import nodes and edges from JSON.
560pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> {
561    let mut graph = AmemReader::read_from_file(path)?;
562    let json_data = std::fs::read_to_string(json_path)?;
563    let parsed: serde_json::Value = serde_json::from_str(&json_data)
564        .map_err(|e| crate::types::AmemError::Compression(e.to_string()))?;
565
566    let mut added_nodes = 0;
567    let mut added_edges = 0;
568
569    if let Some(nodes) = parsed.get("nodes").and_then(|v| v.as_array()) {
570        for node_val in nodes {
571            let event_type = node_val
572                .get("event_type")
573                .and_then(|v| v.as_str())
574                .and_then(EventType::from_name)
575                .unwrap_or(EventType::Fact);
576            let content = node_val
577                .get("content")
578                .and_then(|v| v.as_str())
579                .unwrap_or("");
580            let session_id = node_val
581                .get("session_id")
582                .and_then(|v| v.as_u64())
583                .unwrap_or(0) as u32;
584            let confidence = node_val
585                .get("confidence")
586                .and_then(|v| v.as_f64())
587                .unwrap_or(1.0) as f32;
588
589            let event = CognitiveEventBuilder::new(event_type, content)
590                .session_id(session_id)
591                .confidence(confidence)
592                .build();
593            graph.add_node(event)?;
594            added_nodes += 1;
595        }
596    }
597
598    if let Some(edges) = parsed.get("edges").and_then(|v| v.as_array()) {
599        for edge_val in edges {
600            let source_id = edge_val
601                .get("source_id")
602                .and_then(|v| v.as_u64())
603                .unwrap_or(0);
604            let target_id = edge_val
605                .get("target_id")
606                .and_then(|v| v.as_u64())
607                .unwrap_or(0);
608            let edge_type = edge_val
609                .get("edge_type")
610                .and_then(|v| v.as_str())
611                .and_then(EdgeType::from_name)
612                .unwrap_or(EdgeType::RelatedTo);
613            let weight = edge_val
614                .get("weight")
615                .and_then(|v| v.as_f64())
616                .unwrap_or(1.0) as f32;
617
618            let edge = Edge::new(source_id, target_id, edge_type, weight);
619            if graph.add_edge(edge).is_ok() {
620                added_edges += 1;
621            }
622        }
623    }
624
625    let writer = AmemWriter::new(graph.dimension());
626    writer.write_to_file(&graph, path)?;
627
628    println!("Imported {} nodes and {} edges", added_nodes, added_edges);
629    Ok(())
630}
631
632/// Run decay calculations.
633pub fn cmd_decay(path: &Path, threshold: f32, json: bool) -> AmemResult<()> {
634    let mut graph = AmemReader::read_from_file(path)?;
635    let write_engine = WriteEngine::new(graph.dimension());
636    let current_time = crate::types::now_micros();
637    let report = write_engine.run_decay(&mut graph, current_time)?;
638
639    let writer = AmemWriter::new(graph.dimension());
640    writer.write_to_file(&graph, path)?;
641
642    let low: Vec<u64> = report
643        .low_importance_nodes
644        .iter()
645        .filter(|&&id| {
646            graph
647                .get_node(id)
648                .map(|n| n.decay_score < threshold)
649                .unwrap_or(false)
650        })
651        .copied()
652        .collect();
653
654    if json {
655        let info = serde_json::json!({
656            "nodes_decayed": report.nodes_decayed,
657            "low_importance_count": low.len(),
658            "low_importance_nodes": low,
659        });
660        println!(
661            "{}",
662            serde_json::to_string_pretty(&info).unwrap_or_default()
663        );
664    } else {
665        println!("Decay complete:");
666        println!("  Nodes updated: {}", report.nodes_decayed);
667        println!(
668            "  Low importance (below {}): {} nodes",
669            threshold,
670            low.len()
671        );
672    }
673    Ok(())
674}
675
676/// Detailed statistics.
677pub fn cmd_stats(path: &Path, json: bool) -> AmemResult<()> {
678    let graph = AmemReader::read_from_file(path)?;
679    let file_size = std::fs::metadata(path)?.len();
680
681    let node_count = graph.node_count();
682    let edge_count = graph.edge_count();
683    let avg_edges = if node_count > 0 {
684        edge_count as f64 / node_count as f64
685    } else {
686        0.0
687    };
688    let max_edges = graph
689        .nodes()
690        .iter()
691        .map(|n| graph.edges_from(n.id).len())
692        .max()
693        .unwrap_or(0);
694    let session_count = graph.session_index().session_count();
695    let avg_nodes_per_session = if session_count > 0 {
696        node_count as f64 / session_count as f64
697    } else {
698        0.0
699    };
700
701    // Confidence distribution
702    let mut conf_buckets = [0usize; 5];
703    for node in graph.nodes() {
704        let bucket = ((node.confidence * 5.0).floor() as usize).min(4);
705        conf_buckets[bucket] += 1;
706    }
707
708    if json {
709        let info = serde_json::json!({
710            "nodes": node_count,
711            "edges": edge_count,
712            "avg_edges_per_node": avg_edges,
713            "max_edges_per_node": max_edges,
714            "sessions": session_count,
715            "file_size": file_size,
716        });
717        println!(
718            "{}",
719            serde_json::to_string_pretty(&info).unwrap_or_default()
720        );
721    } else {
722        println!("Graph Statistics:");
723        println!("  Nodes: {}", node_count);
724        println!("  Edges: {}", edge_count);
725        println!("  Avg edges per node: {:.2}", avg_edges);
726        println!("  Max edges per node: {}", max_edges);
727        println!("  Sessions: {}", session_count);
728        println!("  Avg nodes per session: {:.0}", avg_nodes_per_session);
729        println!();
730        println!("  Confidence distribution:");
731        println!("    0.0-0.2: {} nodes", conf_buckets[0]);
732        println!("    0.2-0.4: {} nodes", conf_buckets[1]);
733        println!("    0.4-0.6: {} nodes", conf_buckets[2]);
734        println!("    0.6-0.8: {} nodes", conf_buckets[3]);
735        println!("    0.8-1.0: {} nodes", conf_buckets[4]);
736        println!();
737        println!("  Edge type distribution:");
738        for et_val in 0u8..=6 {
739            if let Some(et) = EdgeType::from_u8(et_val) {
740                let count = graph.edges().iter().filter(|e| e.edge_type == et).count();
741                if count > 0 {
742                    println!("    {}: {}", et.name(), count);
743                }
744            }
745        }
746    }
747    Ok(())
748}
749
750fn format_size(bytes: u64) -> String {
751    if bytes < 1024 {
752        format!("{} B", bytes)
753    } else if bytes < 1024 * 1024 {
754        format!("{:.1} KB", bytes as f64 / 1024.0)
755    } else if bytes < 1024 * 1024 * 1024 {
756        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
757    } else {
758        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
759    }
760}
761
762fn format_timestamp(micros: u64) -> String {
763    let secs = (micros / 1_000_000) as i64;
764    let dt = chrono::DateTime::from_timestamp(secs, 0);
765    match dt {
766        Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
767        None => format!("{} us", micros),
768    }
769}