Skip to main content

agentic_memory/cli/
commands.rs

1//! CLI command implementations.
2
3use std::path::Path;
4
5use crate::engine::{
6    AnalogicalAnchor, AnalogicalParams, BeliefRevisionParams, CausalParams, CentralityAlgorithm,
7    CentralityParams, ConsolidationOp, ConsolidationParams, DriftParams, GapDetectionParams,
8    GapSeverity, HybridSearchParams, MemoryQualityParams, PatternParams, PatternSort, QueryEngine,
9    ShortestPathParams, TextSearchParams, TraversalParams, WriteEngine,
10};
11use crate::format::{AmemReader, AmemWriter};
12use crate::graph::traversal::TraversalDirection;
13use crate::graph::MemoryGraph;
14use crate::types::{AmemResult, CognitiveEvent, CognitiveEventBuilder, Edge, EdgeType, EventType};
15
16/// Create a new empty .amem file.
17pub fn cmd_create(path: &Path, dimension: usize) -> AmemResult<()> {
18    let graph = MemoryGraph::new(dimension);
19    let writer = AmemWriter::new(dimension);
20    writer.write_to_file(&graph, path)?;
21    println!("Created {}", path.display());
22    Ok(())
23}
24
25/// Display information about an .amem file.
26pub fn cmd_info(path: &Path, json: bool) -> AmemResult<()> {
27    let graph = AmemReader::read_from_file(path)?;
28    let file_size = std::fs::metadata(path)?.len();
29
30    if json {
31        let info = serde_json::json!({
32            "file": path.display().to_string(),
33            "version": 1,
34            "dimension": graph.dimension(),
35            "nodes": graph.node_count(),
36            "edges": graph.edge_count(),
37            "sessions": graph.session_index().session_count(),
38            "file_size": file_size,
39            "node_types": {
40                "facts": graph.type_index().count(EventType::Fact),
41                "decisions": graph.type_index().count(EventType::Decision),
42                "inferences": graph.type_index().count(EventType::Inference),
43                "corrections": graph.type_index().count(EventType::Correction),
44                "skills": graph.type_index().count(EventType::Skill),
45                "episodes": graph.type_index().count(EventType::Episode),
46            }
47        });
48        println!(
49            "{}",
50            serde_json::to_string_pretty(&info).unwrap_or_default()
51        );
52    } else {
53        println!("File: {}", path.display());
54        println!("Version: 1");
55        println!("Dimension: {}", graph.dimension());
56        println!("Nodes: {}", graph.node_count());
57        println!("Edges: {}", graph.edge_count());
58        println!("Sessions: {}", graph.session_index().session_count());
59        println!("File size: {}", format_size(file_size));
60        println!("Node types:");
61        println!("  Facts: {}", graph.type_index().count(EventType::Fact));
62        println!(
63            "  Decisions: {}",
64            graph.type_index().count(EventType::Decision)
65        );
66        println!(
67            "  Inferences: {}",
68            graph.type_index().count(EventType::Inference)
69        );
70        println!(
71            "  Corrections: {}",
72            graph.type_index().count(EventType::Correction)
73        );
74        println!("  Skills: {}", graph.type_index().count(EventType::Skill));
75        println!(
76            "  Episodes: {}",
77            graph.type_index().count(EventType::Episode)
78        );
79    }
80    Ok(())
81}
82
83/// Add a cognitive event to the graph.
84pub fn cmd_add(
85    path: &Path,
86    event_type: EventType,
87    content: &str,
88    session_id: u32,
89    confidence: f32,
90    supersedes: Option<u64>,
91    json: bool,
92) -> AmemResult<()> {
93    let mut graph = AmemReader::read_from_file(path)?;
94    let write_engine = WriteEngine::new(graph.dimension());
95
96    let id = if let Some(old_id) = supersedes {
97        write_engine.correct(&mut graph, old_id, content, session_id)?
98    } else {
99        let event = CognitiveEventBuilder::new(event_type, content)
100            .session_id(session_id)
101            .confidence(confidence)
102            .build();
103        graph.add_node(event)?
104    };
105
106    let writer = AmemWriter::new(graph.dimension());
107    writer.write_to_file(&graph, path)?;
108
109    if json {
110        println!(
111            "{}",
112            serde_json::json!({"id": id, "type": event_type.name()})
113        );
114    } else {
115        println!(
116            "Added node {} ({}) to {}",
117            id,
118            event_type.name(),
119            path.display()
120        );
121    }
122    Ok(())
123}
124
125/// Add an edge between two nodes.
126pub fn cmd_link(
127    path: &Path,
128    source_id: u64,
129    target_id: u64,
130    edge_type: EdgeType,
131    weight: f32,
132    json: bool,
133) -> AmemResult<()> {
134    let mut graph = AmemReader::read_from_file(path)?;
135    let edge = Edge::new(source_id, target_id, edge_type, weight);
136    graph.add_edge(edge)?;
137
138    let writer = AmemWriter::new(graph.dimension());
139    writer.write_to_file(&graph, path)?;
140
141    if json {
142        println!(
143            "{}",
144            serde_json::json!({"source": source_id, "target": target_id, "type": edge_type.name()})
145        );
146    } else {
147        println!(
148            "Linked {} --{}--> {}",
149            source_id,
150            edge_type.name(),
151            target_id
152        );
153    }
154    Ok(())
155}
156
157/// Get a specific node by ID.
158pub fn cmd_get(path: &Path, node_id: u64, json: bool) -> AmemResult<()> {
159    let graph = AmemReader::read_from_file(path)?;
160    let node = graph
161        .get_node(node_id)
162        .ok_or(crate::types::AmemError::NodeNotFound(node_id))?;
163
164    let edges_out = graph.edges_from(node_id).len();
165    let edges_in = graph.edges_to(node_id).len();
166
167    if json {
168        let info = serde_json::json!({
169            "id": node.id,
170            "type": node.event_type.name(),
171            "created_at": node.created_at,
172            "session_id": node.session_id,
173            "confidence": node.confidence,
174            "access_count": node.access_count,
175            "decay_score": node.decay_score,
176            "content": node.content,
177            "edges_out": edges_out,
178            "edges_in": edges_in,
179        });
180        println!(
181            "{}",
182            serde_json::to_string_pretty(&info).unwrap_or_default()
183        );
184    } else {
185        println!("Node {}", node.id);
186        println!("  Type: {}", node.event_type.name());
187        println!("  Created: {}", format_timestamp(node.created_at));
188        println!("  Session: {}", node.session_id);
189        println!("  Confidence: {:.2}", node.confidence);
190        println!("  Access count: {}", node.access_count);
191        println!("  Decay score: {:.2}", node.decay_score);
192        println!("  Content: {:?}", node.content);
193        println!("  Edges out: {}", edges_out);
194        println!("  Edges in: {}", edges_in);
195    }
196    Ok(())
197}
198
199/// Run a traversal query.
200#[allow(clippy::too_many_arguments)]
201pub fn cmd_traverse(
202    path: &Path,
203    start_id: u64,
204    edge_types: Vec<EdgeType>,
205    direction: TraversalDirection,
206    max_depth: u32,
207    max_results: usize,
208    min_confidence: f32,
209    json: bool,
210) -> AmemResult<()> {
211    let graph = AmemReader::read_from_file(path)?;
212    let query_engine = QueryEngine::new();
213
214    let et = if edge_types.is_empty() {
215        vec![
216            EdgeType::CausedBy,
217            EdgeType::Supports,
218            EdgeType::Contradicts,
219            EdgeType::Supersedes,
220            EdgeType::RelatedTo,
221            EdgeType::PartOf,
222            EdgeType::TemporalNext,
223        ]
224    } else {
225        edge_types
226    };
227
228    let result = query_engine.traverse(
229        &graph,
230        TraversalParams {
231            start_id,
232            edge_types: et,
233            direction,
234            max_depth,
235            max_results,
236            min_confidence,
237        },
238    )?;
239
240    if json {
241        let nodes_info: Vec<serde_json::Value> = result
242            .visited
243            .iter()
244            .map(|&id| {
245                let depth = result.depths.get(&id).copied().unwrap_or(0);
246                if let Some(node) = graph.get_node(id) {
247                    serde_json::json!({
248                        "id": id,
249                        "depth": depth,
250                        "type": node.event_type.name(),
251                        "content": node.content,
252                    })
253                } else {
254                    serde_json::json!({"id": id, "depth": depth})
255                }
256            })
257            .collect();
258        println!(
259            "{}",
260            serde_json::to_string_pretty(&nodes_info).unwrap_or_default()
261        );
262    } else {
263        for &id in &result.visited {
264            let depth = result.depths.get(&id).copied().unwrap_or(0);
265            let indent = "  ".repeat(depth as usize);
266            if let Some(node) = graph.get_node(id) {
267                println!(
268                    "{}[depth {}] Node {} ({}): {:?}",
269                    indent,
270                    depth,
271                    id,
272                    node.event_type.name(),
273                    node.content
274                );
275            }
276        }
277    }
278    Ok(())
279}
280
281/// Pattern search.
282#[allow(clippy::too_many_arguments)]
283pub fn cmd_search(
284    path: &Path,
285    event_types: Vec<EventType>,
286    session_ids: Vec<u32>,
287    min_confidence: Option<f32>,
288    max_confidence: Option<f32>,
289    created_after: Option<u64>,
290    created_before: Option<u64>,
291    sort_by: PatternSort,
292    limit: usize,
293    json: bool,
294) -> AmemResult<()> {
295    let graph = AmemReader::read_from_file(path)?;
296    let query_engine = QueryEngine::new();
297
298    let results = query_engine.pattern(
299        &graph,
300        PatternParams {
301            event_types,
302            min_confidence,
303            max_confidence,
304            session_ids,
305            created_after,
306            created_before,
307            min_decay_score: None,
308            max_results: limit,
309            sort_by,
310        },
311    )?;
312
313    if json {
314        let nodes: Vec<serde_json::Value> = results
315            .iter()
316            .map(|node| {
317                serde_json::json!({
318                    "id": node.id,
319                    "type": node.event_type.name(),
320                    "confidence": node.confidence,
321                    "content": node.content,
322                    "session_id": node.session_id,
323                })
324            })
325            .collect();
326        println!(
327            "{}",
328            serde_json::to_string_pretty(&nodes).unwrap_or_default()
329        );
330    } else {
331        for node in &results {
332            println!(
333                "Node {} ({}, confidence: {:.2}): {:?}",
334                node.id,
335                node.event_type.name(),
336                node.confidence,
337                node.content
338            );
339        }
340        println!("\n{} results", results.len());
341    }
342    Ok(())
343}
344
345/// Causal impact analysis.
346pub fn cmd_impact(path: &Path, node_id: u64, max_depth: u32, json: bool) -> AmemResult<()> {
347    let graph = AmemReader::read_from_file(path)?;
348    let query_engine = QueryEngine::new();
349
350    let result = query_engine.causal(
351        &graph,
352        CausalParams {
353            node_id,
354            max_depth,
355            dependency_types: vec![EdgeType::CausedBy, EdgeType::Supports],
356        },
357    )?;
358
359    if json {
360        let info = serde_json::json!({
361            "root_id": result.root_id,
362            "direct_dependents": result.dependency_tree.get(&node_id).map(|v| v.len()).unwrap_or(0),
363            "total_dependents": result.dependents.len(),
364            "affected_decisions": result.affected_decisions,
365            "affected_inferences": result.affected_inferences,
366            "dependents": result.dependents,
367        });
368        println!(
369            "{}",
370            serde_json::to_string_pretty(&info).unwrap_or_default()
371        );
372    } else {
373        println!("Impact analysis for node {}", node_id);
374        let direct = result
375            .dependency_tree
376            .get(&node_id)
377            .map(|v| v.len())
378            .unwrap_or(0);
379        println!("  Direct dependents: {}", direct);
380        println!("  Total dependents: {}", result.dependents.len());
381        println!("  Affected decisions: {}", result.affected_decisions);
382        println!("  Affected inferences: {}", result.affected_inferences);
383
384        if !result.dependents.is_empty() {
385            println!("\nDependency tree:");
386            print_dependency_tree(&graph, &result.dependency_tree, node_id, 1);
387        }
388    }
389    Ok(())
390}
391
392fn print_dependency_tree(
393    graph: &MemoryGraph,
394    tree: &std::collections::HashMap<u64, Vec<(u64, EdgeType)>>,
395    node_id: u64,
396    depth: usize,
397) {
398    if let Some(deps) = tree.get(&node_id) {
399        for (dep_id, edge_type) in deps {
400            let indent = "  ".repeat(depth);
401            if let Some(node) = graph.get_node(*dep_id) {
402                println!(
403                    "{}<- Node {} ({}, {})",
404                    indent,
405                    dep_id,
406                    node.event_type.name(),
407                    edge_type.name()
408                );
409            }
410            print_dependency_tree(graph, tree, *dep_id, depth + 1);
411        }
412    }
413}
414
415/// Resolve a node through SUPERSEDES chains.
416pub fn cmd_resolve(path: &Path, node_id: u64, json: bool) -> AmemResult<()> {
417    let graph = AmemReader::read_from_file(path)?;
418    let query_engine = QueryEngine::new();
419
420    let resolved = query_engine.resolve(&graph, node_id)?;
421
422    if json {
423        let info = serde_json::json!({
424            "original_id": node_id,
425            "resolved_id": resolved.id,
426            "type": resolved.event_type.name(),
427            "content": resolved.content,
428        });
429        println!(
430            "{}",
431            serde_json::to_string_pretty(&info).unwrap_or_default()
432        );
433    } else {
434        if resolved.id != node_id {
435            // Show chain
436            let mut chain = vec![node_id];
437            let mut current = node_id;
438            for _ in 0..100 {
439                let mut next = None;
440                for edge in graph.edges_to(current) {
441                    if edge.edge_type == EdgeType::Supersedes {
442                        next = Some(edge.source_id);
443                        break;
444                    }
445                }
446                match next {
447                    Some(n) => {
448                        chain.push(n);
449                        current = n;
450                    }
451                    None => break,
452                }
453            }
454            let chain_str: Vec<String> = chain.iter().map(|id| format!("Node {}", id)).collect();
455            println!("{} (current)", chain_str.join(" -> superseded by -> "));
456        } else {
457            println!("Node {} is already the current version", node_id);
458        }
459        println!("\nCurrent version:");
460        println!("  Node {}", resolved.id);
461        println!("  Type: {}", resolved.event_type.name());
462        println!("  Content: {:?}", resolved.content);
463    }
464    Ok(())
465}
466
467/// List sessions.
468pub fn cmd_sessions(path: &Path, limit: usize, json: bool) -> AmemResult<()> {
469    let graph = AmemReader::read_from_file(path)?;
470    let session_ids = graph.session_index().session_ids();
471
472    if json {
473        let sessions: Vec<serde_json::Value> = session_ids
474            .iter()
475            .rev()
476            .take(limit)
477            .map(|&sid| {
478                serde_json::json!({
479                    "session_id": sid,
480                    "node_count": graph.session_index().node_count(sid),
481                })
482            })
483            .collect();
484        println!(
485            "{}",
486            serde_json::to_string_pretty(&sessions).unwrap_or_default()
487        );
488    } else {
489        println!("Sessions in {}:", path.display());
490        for &sid in session_ids.iter().rev().take(limit) {
491            let count = graph.session_index().node_count(sid);
492            println!("  Session {}: {} nodes", sid, count);
493        }
494        println!("  Total: {} sessions", session_ids.len());
495    }
496    Ok(())
497}
498
499/// Export graph as JSON.
500pub fn cmd_export(
501    path: &Path,
502    nodes_only: bool,
503    session: Option<u32>,
504    pretty: bool,
505) -> AmemResult<()> {
506    let graph = AmemReader::read_from_file(path)?;
507
508    let nodes: Vec<&CognitiveEvent> = if let Some(sid) = session {
509        let ids = graph.session_index().get_session(sid);
510        ids.iter().filter_map(|&id| graph.get_node(id)).collect()
511    } else {
512        graph.nodes().iter().collect()
513    };
514
515    let nodes_json: Vec<serde_json::Value> = nodes
516        .iter()
517        .map(|n| {
518            serde_json::json!({
519                "id": n.id,
520                "event_type": n.event_type.name(),
521                "created_at": n.created_at,
522                "session_id": n.session_id,
523                "confidence": n.confidence,
524                "access_count": n.access_count,
525                "last_accessed": n.last_accessed,
526                "decay_score": n.decay_score,
527                "content": n.content,
528            })
529        })
530        .collect();
531
532    let output = if nodes_only {
533        serde_json::json!({"nodes": nodes_json})
534    } else {
535        let edges_json: Vec<serde_json::Value> = graph
536            .edges()
537            .iter()
538            .map(|e| {
539                serde_json::json!({
540                    "source_id": e.source_id,
541                    "target_id": e.target_id,
542                    "edge_type": e.edge_type.name(),
543                    "weight": e.weight,
544                    "created_at": e.created_at,
545                })
546            })
547            .collect();
548        serde_json::json!({"nodes": nodes_json, "edges": edges_json})
549    };
550
551    if pretty {
552        println!(
553            "{}",
554            serde_json::to_string_pretty(&output).unwrap_or_default()
555        );
556    } else {
557        println!("{}", serde_json::to_string(&output).unwrap_or_default());
558    }
559    Ok(())
560}
561
562/// Import nodes and edges from JSON.
563pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> {
564    let mut graph = AmemReader::read_from_file(path)?;
565    let json_data = std::fs::read_to_string(json_path)?;
566    let parsed: serde_json::Value = serde_json::from_str(&json_data)
567        .map_err(|e| crate::types::AmemError::Compression(e.to_string()))?;
568
569    let mut added_nodes = 0;
570    let mut added_edges = 0;
571
572    if let Some(nodes) = parsed.get("nodes").and_then(|v| v.as_array()) {
573        for node_val in nodes {
574            let event_type = node_val
575                .get("event_type")
576                .and_then(|v| v.as_str())
577                .and_then(EventType::from_name)
578                .unwrap_or(EventType::Fact);
579            let content = node_val
580                .get("content")
581                .and_then(|v| v.as_str())
582                .unwrap_or("");
583            let session_id = node_val
584                .get("session_id")
585                .and_then(|v| v.as_u64())
586                .unwrap_or(0) as u32;
587            let confidence = node_val
588                .get("confidence")
589                .and_then(|v| v.as_f64())
590                .unwrap_or(1.0) as f32;
591
592            let event = CognitiveEventBuilder::new(event_type, content)
593                .session_id(session_id)
594                .confidence(confidence)
595                .build();
596            graph.add_node(event)?;
597            added_nodes += 1;
598        }
599    }
600
601    if let Some(edges) = parsed.get("edges").and_then(|v| v.as_array()) {
602        for edge_val in edges {
603            let source_id = edge_val
604                .get("source_id")
605                .and_then(|v| v.as_u64())
606                .unwrap_or(0);
607            let target_id = edge_val
608                .get("target_id")
609                .and_then(|v| v.as_u64())
610                .unwrap_or(0);
611            let edge_type = edge_val
612                .get("edge_type")
613                .and_then(|v| v.as_str())
614                .and_then(EdgeType::from_name)
615                .unwrap_or(EdgeType::RelatedTo);
616            let weight = edge_val
617                .get("weight")
618                .and_then(|v| v.as_f64())
619                .unwrap_or(1.0) as f32;
620
621            let edge = Edge::new(source_id, target_id, edge_type, weight);
622            if graph.add_edge(edge).is_ok() {
623                added_edges += 1;
624            }
625        }
626    }
627
628    let writer = AmemWriter::new(graph.dimension());
629    writer.write_to_file(&graph, path)?;
630
631    println!("Imported {} nodes and {} edges", added_nodes, added_edges);
632    Ok(())
633}
634
635/// Run decay calculations.
636pub fn cmd_decay(path: &Path, threshold: f32, json: bool) -> AmemResult<()> {
637    let mut graph = AmemReader::read_from_file(path)?;
638    let write_engine = WriteEngine::new(graph.dimension());
639    let current_time = crate::types::now_micros();
640    let report = write_engine.run_decay(&mut graph, current_time)?;
641
642    let writer = AmemWriter::new(graph.dimension());
643    writer.write_to_file(&graph, path)?;
644
645    let low: Vec<u64> = report
646        .low_importance_nodes
647        .iter()
648        .filter(|&&id| {
649            graph
650                .get_node(id)
651                .map(|n| n.decay_score < threshold)
652                .unwrap_or(false)
653        })
654        .copied()
655        .collect();
656
657    if json {
658        let info = serde_json::json!({
659            "nodes_decayed": report.nodes_decayed,
660            "low_importance_count": low.len(),
661            "low_importance_nodes": low,
662        });
663        println!(
664            "{}",
665            serde_json::to_string_pretty(&info).unwrap_or_default()
666        );
667    } else {
668        println!("Decay complete:");
669        println!("  Nodes updated: {}", report.nodes_decayed);
670        println!(
671            "  Low importance (below {}): {} nodes",
672            threshold,
673            low.len()
674        );
675    }
676    Ok(())
677}
678
679/// Detailed statistics.
680pub fn cmd_stats(path: &Path, json: bool) -> AmemResult<()> {
681    let graph = AmemReader::read_from_file(path)?;
682    let file_size = std::fs::metadata(path)?.len();
683
684    let node_count = graph.node_count();
685    let edge_count = graph.edge_count();
686    let avg_edges = if node_count > 0 {
687        edge_count as f64 / node_count as f64
688    } else {
689        0.0
690    };
691    let max_edges = graph
692        .nodes()
693        .iter()
694        .map(|n| graph.edges_from(n.id).len())
695        .max()
696        .unwrap_or(0);
697    let session_count = graph.session_index().session_count();
698    let avg_nodes_per_session = if session_count > 0 {
699        node_count as f64 / session_count as f64
700    } else {
701        0.0
702    };
703
704    // Confidence distribution
705    let mut conf_buckets = [0usize; 5];
706    for node in graph.nodes() {
707        let bucket = ((node.confidence * 5.0).floor() as usize).min(4);
708        conf_buckets[bucket] += 1;
709    }
710
711    if json {
712        let info = serde_json::json!({
713            "nodes": node_count,
714            "edges": edge_count,
715            "avg_edges_per_node": avg_edges,
716            "max_edges_per_node": max_edges,
717            "sessions": session_count,
718            "file_size": file_size,
719        });
720        println!(
721            "{}",
722            serde_json::to_string_pretty(&info).unwrap_or_default()
723        );
724    } else {
725        println!("Graph Statistics:");
726        println!("  Nodes: {}", node_count);
727        println!("  Edges: {}", edge_count);
728        println!("  Avg edges per node: {:.2}", avg_edges);
729        println!("  Max edges per node: {}", max_edges);
730        println!("  Sessions: {}", session_count);
731        println!("  Avg nodes per session: {:.0}", avg_nodes_per_session);
732        println!();
733        println!("  Confidence distribution:");
734        println!("    0.0-0.2: {} nodes", conf_buckets[0]);
735        println!("    0.2-0.4: {} nodes", conf_buckets[1]);
736        println!("    0.4-0.6: {} nodes", conf_buckets[2]);
737        println!("    0.6-0.8: {} nodes", conf_buckets[3]);
738        println!("    0.8-1.0: {} nodes", conf_buckets[4]);
739        println!();
740        println!("  Edge type distribution:");
741        for et_val in 0u8..=6 {
742            if let Some(et) = EdgeType::from_u8(et_val) {
743                let count = graph.edges().iter().filter(|e| e.edge_type == et).count();
744                if count > 0 {
745                    println!("    {}: {}", et.name(), count);
746                }
747            }
748        }
749    }
750    Ok(())
751}
752
753/// Graph-wide quality report (confidence, staleness, structural health).
754pub fn cmd_quality(
755    path: &Path,
756    low_confidence: f32,
757    stale_decay: f32,
758    limit: usize,
759    json: bool,
760) -> AmemResult<()> {
761    let graph = AmemReader::read_from_file(path)?;
762    let query_engine = QueryEngine::new();
763    let report = query_engine.memory_quality(
764        &graph,
765        MemoryQualityParams {
766            low_confidence_threshold: low_confidence.clamp(0.0, 1.0),
767            stale_decay_threshold: stale_decay.clamp(0.0, 1.0),
768            max_examples: limit.max(1),
769        },
770    )?;
771
772    if json {
773        let out = serde_json::json!({
774            "status": report.status,
775            "summary": {
776                "nodes": report.node_count,
777                "edges": report.edge_count,
778                "low_confidence_count": report.low_confidence_count,
779                "stale_count": report.stale_count,
780                "orphan_count": report.orphan_count,
781                "decisions_without_support_count": report.decisions_without_support_count,
782                "contradiction_edges": report.contradiction_edges,
783                "supersedes_edges": report.supersedes_edges,
784            },
785            "examples": {
786                "low_confidence": report.low_confidence_examples,
787                "stale": report.stale_examples,
788                "orphan": report.orphan_examples,
789                "unsupported_decisions": report.unsupported_decision_examples,
790            }
791        });
792        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
793    } else {
794        println!("Memory quality report for {}", path.display());
795        println!("  Status: {}", report.status.to_uppercase());
796        println!("  Nodes: {}", report.node_count);
797        println!("  Edges: {}", report.edge_count);
798        println!(
799            "  Weak confidence (<{:.2}): {}",
800            low_confidence, report.low_confidence_count
801        );
802        println!("  Stale (<{:.2}): {}", stale_decay, report.stale_count);
803        println!("  Orphan nodes: {}", report.orphan_count);
804        println!(
805            "  Decisions without support edges: {}",
806            report.decisions_without_support_count
807        );
808        println!("  Contradiction edges: {}", report.contradiction_edges);
809        println!("  Supersedes edges: {}", report.supersedes_edges);
810        if !report.low_confidence_examples.is_empty() {
811            println!(
812                "  Low-confidence examples: {:?}",
813                report.low_confidence_examples
814            );
815        }
816        if !report.unsupported_decision_examples.is_empty() {
817            println!(
818                "  Unsupported decision examples: {:?}",
819                report.unsupported_decision_examples
820            );
821        }
822        println!(
823            "  Next: amem runtime-sync {} --workspace . --write-episode",
824            path.display()
825        );
826    }
827
828    Ok(())
829}
830
831#[derive(Default)]
832struct ArtifactScanReport {
833    amem_files: Vec<std::path::PathBuf>,
834    acb_files: Vec<std::path::PathBuf>,
835    avis_files: Vec<std::path::PathBuf>,
836    io_errors: usize,
837}
838
839/// Scan a workspace for sister artifacts and optionally write an episode memory.
840pub fn cmd_runtime_sync(
841    path: &Path,
842    workspace: &Path,
843    max_depth: u32,
844    session_id: u32,
845    write_episode: bool,
846    json: bool,
847) -> AmemResult<()> {
848    let mut graph = AmemReader::read_from_file(path)?;
849    let report = scan_workspace_artifacts(workspace, max_depth);
850
851    let mut episode_id = None;
852    if write_episode {
853        let sid = if session_id == 0 {
854            graph
855                .session_index()
856                .session_ids()
857                .iter()
858                .copied()
859                .max()
860                .unwrap_or(0)
861        } else {
862            session_id
863        };
864        let content = format!(
865            "Runtime sync snapshot for workspace {}: amem={} acb={} avis={} (depth={})",
866            workspace.display(),
867            report.amem_files.len(),
868            report.acb_files.len(),
869            report.avis_files.len(),
870            max_depth
871        );
872        let event = CognitiveEventBuilder::new(EventType::Episode, content)
873            .session_id(sid)
874            .confidence(0.95)
875            .build();
876        let id = graph.add_node(event)?;
877        episode_id = Some(id);
878        let writer = AmemWriter::new(graph.dimension());
879        writer.write_to_file(&graph, path)?;
880    }
881
882    if json {
883        let out = serde_json::json!({
884            "workspace": workspace.display().to_string(),
885            "max_depth": max_depth,
886            "amem_count": report.amem_files.len(),
887            "acb_count": report.acb_files.len(),
888            "avis_count": report.avis_files.len(),
889            "io_errors": report.io_errors,
890            "episode_written": episode_id.is_some(),
891            "episode_id": episode_id,
892            "sample": {
893                "amem": report.amem_files.iter().take(5).map(|p| p.display().to_string()).collect::<Vec<_>>(),
894                "acb": report.acb_files.iter().take(5).map(|p| p.display().to_string()).collect::<Vec<_>>(),
895                "avis": report.avis_files.iter().take(5).map(|p| p.display().to_string()).collect::<Vec<_>>(),
896            }
897        });
898        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
899    } else {
900        println!(
901            "Runtime sync scan in {} (depth {})",
902            workspace.display(),
903            max_depth
904        );
905        println!("  .amem files: {}", report.amem_files.len());
906        println!("  .acb files: {}", report.acb_files.len());
907        println!("  .avis files: {}", report.avis_files.len());
908        if report.io_errors > 0 {
909            println!("  Scan IO errors: {}", report.io_errors);
910        }
911        if let Some(id) = episode_id {
912            println!("  Wrote episode node: {}", id);
913        } else {
914            println!("  Episode write: skipped");
915        }
916    }
917
918    Ok(())
919}
920
921/// Estimate long-horizon storage growth vs configured budget.
922pub fn cmd_budget(path: &Path, max_bytes: u64, horizon_years: u32, json: bool) -> AmemResult<()> {
923    let graph = AmemReader::read_from_file(path)?;
924    let current_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
925    let projected = estimate_projected_size(&graph, current_size, horizon_years);
926    let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
927
928    // Daily budget guidance.
929    let years = horizon_years.max(1) as f64;
930    let bytes_per_day = max_bytes as f64 / (years * 365.25);
931
932    if json {
933        let out = serde_json::json!({
934            "file": path.display().to_string(),
935            "current_size_bytes": current_size,
936            "projected_size_bytes": projected,
937            "max_budget_bytes": max_bytes,
938            "horizon_years": horizon_years,
939            "over_budget": over_budget,
940            "daily_budget_bytes": bytes_per_day,
941            "daily_budget_kb": bytes_per_day / 1024.0,
942            "guidance": {
943                "recommended_policy_mode": if over_budget { "auto-rollup" } else { "warn" },
944                "env": {
945                    "AMEM_STORAGE_BUDGET_MODE": "auto-rollup|warn|off",
946                    "AMEM_STORAGE_BUDGET_BYTES": max_bytes,
947                    "AMEM_STORAGE_BUDGET_HORIZON_YEARS": horizon_years
948                }
949            }
950        });
951        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
952    } else {
953        println!("Storage budget estimate for {}", path.display());
954        println!("  Current size: {}", format_size(current_size));
955        if let Some(v) = projected {
956            println!("  Projected size ({}y): {}", horizon_years, format_size(v));
957        } else {
958            println!(
959                "  Projected size ({}y): unavailable (insufficient timeline history)",
960                horizon_years
961            );
962        }
963        println!("  Budget target: {}", format_size(max_bytes));
964        println!("  Over budget: {}", if over_budget { "yes" } else { "no" });
965        println!(
966            "  Daily budget guidance: {:.1} KB/day",
967            bytes_per_day / 1024.0
968        );
969        println!("  Suggested env:");
970        println!("    AMEM_STORAGE_BUDGET_MODE=auto-rollup");
971        println!("    AMEM_STORAGE_BUDGET_BYTES={}", max_bytes);
972        println!("    AMEM_STORAGE_BUDGET_HORIZON_YEARS={}", horizon_years);
973    }
974
975    Ok(())
976}
977
978fn scan_workspace_artifacts(root: &Path, max_depth: u32) -> ArtifactScanReport {
979    let mut report = ArtifactScanReport::default();
980    scan_dir_recursive(root, 0, max_depth, &mut report);
981    report
982}
983
984fn scan_dir_recursive(path: &Path, depth: u32, max_depth: u32, report: &mut ArtifactScanReport) {
985    if depth > max_depth {
986        return;
987    }
988    let entries = match std::fs::read_dir(path) {
989        Ok(v) => v,
990        Err(_) => {
991            report.io_errors += 1;
992            return;
993        }
994    };
995
996    for entry in entries {
997        let entry = match entry {
998            Ok(v) => v,
999            Err(_) => {
1000                report.io_errors += 1;
1001                continue;
1002            }
1003        };
1004        let p = entry.path();
1005        if p.is_dir() {
1006            if should_skip_dir(&p) {
1007                continue;
1008            }
1009            scan_dir_recursive(&p, depth + 1, max_depth, report);
1010            continue;
1011        }
1012        let Some(ext) = p.extension().and_then(|e| e.to_str()) else {
1013            continue;
1014        };
1015        match ext.to_ascii_lowercase().as_str() {
1016            "amem" => report.amem_files.push(p),
1017            "acb" => report.acb_files.push(p),
1018            "avis" => report.avis_files.push(p),
1019            _ => {}
1020        }
1021    }
1022}
1023
1024fn should_skip_dir(path: &Path) -> bool {
1025    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
1026        return false;
1027    };
1028    matches!(
1029        name,
1030        ".git" | "target" | "node_modules" | ".venv" | ".idea" | ".vscode" | "__pycache__"
1031    )
1032}
1033
1034fn estimate_projected_size(
1035    graph: &MemoryGraph,
1036    current_size: u64,
1037    horizon_years: u32,
1038) -> Option<u64> {
1039    if current_size == 0 || graph.node_count() < 2 {
1040        return None;
1041    }
1042
1043    let mut min_ts = u64::MAX;
1044    let mut max_ts = 0u64;
1045    for node in graph.nodes() {
1046        min_ts = min_ts.min(node.created_at);
1047        max_ts = max_ts.max(node.created_at);
1048    }
1049    if min_ts == u64::MAX || max_ts <= min_ts {
1050        return None;
1051    }
1052
1053    let span_secs_raw = (max_ts - min_ts) / 1_000_000;
1054    let span_secs = span_secs_raw.max(7 * 24 * 3600) as f64;
1055    let per_sec = current_size as f64 / span_secs;
1056    let horizon_secs = (horizon_years.max(1) as f64) * 365.25 * 24.0 * 3600.0;
1057    let projected = (per_sec * horizon_secs).round();
1058    Some(projected.max(0.0).min(u64::MAX as f64) as u64)
1059}
1060
1061fn format_size(bytes: u64) -> String {
1062    if bytes < 1024 {
1063        format!("{} B", bytes)
1064    } else if bytes < 1024 * 1024 {
1065        format!("{:.1} KB", bytes as f64 / 1024.0)
1066    } else if bytes < 1024 * 1024 * 1024 {
1067        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1068    } else {
1069        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
1070    }
1071}
1072
1073fn format_timestamp(micros: u64) -> String {
1074    let secs = (micros / 1_000_000) as i64;
1075    let dt = chrono::DateTime::from_timestamp(secs, 0);
1076    match dt {
1077        Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
1078        None => format!("{} us", micros),
1079    }
1080}
1081
1082// ==================== New Query Expansion Commands ====================
1083
1084/// BM25 text search.
1085pub fn cmd_text_search(
1086    path: &Path,
1087    query: &str,
1088    event_types: Vec<EventType>,
1089    session_ids: Vec<u32>,
1090    limit: usize,
1091    min_score: f32,
1092    json: bool,
1093) -> AmemResult<()> {
1094    let graph = AmemReader::read_from_file(path)?;
1095    let query_engine = QueryEngine::new();
1096
1097    let start = std::time::Instant::now();
1098    let results = query_engine.text_search(
1099        &graph,
1100        graph.term_index(),
1101        graph.doc_lengths(),
1102        TextSearchParams {
1103            query: query.to_string(),
1104            max_results: limit,
1105            event_types,
1106            session_ids,
1107            min_score,
1108        },
1109    )?;
1110    let elapsed = start.elapsed();
1111
1112    if json {
1113        let matches: Vec<serde_json::Value> = results
1114            .iter()
1115            .enumerate()
1116            .map(|(i, m)| {
1117                let node = graph.get_node(m.node_id);
1118                serde_json::json!({
1119                    "rank": i + 1,
1120                    "node_id": m.node_id,
1121                    "score": m.score,
1122                    "matched_terms": m.matched_terms,
1123                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1124                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1125                })
1126            })
1127            .collect();
1128        println!(
1129            "{}",
1130            serde_json::to_string_pretty(&serde_json::json!({
1131                "query": query,
1132                "results": matches,
1133                "total": results.len(),
1134                "elapsed_ms": elapsed.as_secs_f64() * 1000.0,
1135            }))
1136            .unwrap_or_default()
1137        );
1138    } else {
1139        println!("Text search for {:?} in {}:", query, path.display());
1140        for (i, m) in results.iter().enumerate() {
1141            if let Some(node) = graph.get_node(m.node_id) {
1142                let preview = if node.content.len() > 60 {
1143                    format!("{}...", &node.content[..60])
1144                } else {
1145                    node.content.clone()
1146                };
1147                println!(
1148                    "  #{:<3} Node {} ({}) [score: {:.2}]  {:?}",
1149                    i + 1,
1150                    m.node_id,
1151                    node.event_type.name(),
1152                    m.score,
1153                    preview
1154                );
1155            }
1156        }
1157        println!(
1158            "  {} results ({:.1}ms)",
1159            results.len(),
1160            elapsed.as_secs_f64() * 1000.0
1161        );
1162    }
1163    Ok(())
1164}
1165
1166/// Hybrid BM25 + vector search.
1167#[allow(clippy::too_many_arguments)]
1168pub fn cmd_hybrid_search(
1169    path: &Path,
1170    query: &str,
1171    text_weight: f32,
1172    vec_weight: f32,
1173    limit: usize,
1174    event_types: Vec<EventType>,
1175    json: bool,
1176) -> AmemResult<()> {
1177    let graph = AmemReader::read_from_file(path)?;
1178    let query_engine = QueryEngine::new();
1179
1180    let results = query_engine.hybrid_search(
1181        &graph,
1182        graph.term_index(),
1183        graph.doc_lengths(),
1184        HybridSearchParams {
1185            query_text: query.to_string(),
1186            query_vec: None,
1187            max_results: limit,
1188            event_types,
1189            text_weight,
1190            vector_weight: vec_weight,
1191            rrf_k: 60,
1192        },
1193    )?;
1194
1195    if json {
1196        let matches: Vec<serde_json::Value> = results
1197            .iter()
1198            .enumerate()
1199            .map(|(i, m)| {
1200                let node = graph.get_node(m.node_id);
1201                serde_json::json!({
1202                    "rank": i + 1,
1203                    "node_id": m.node_id,
1204                    "combined_score": m.combined_score,
1205                    "text_rank": m.text_rank,
1206                    "vector_rank": m.vector_rank,
1207                    "text_score": m.text_score,
1208                    "vector_similarity": m.vector_similarity,
1209                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1210                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1211                })
1212            })
1213            .collect();
1214        println!(
1215            "{}",
1216            serde_json::to_string_pretty(&serde_json::json!({
1217                "query": query,
1218                "results": matches,
1219                "total": results.len(),
1220            }))
1221            .unwrap_or_default()
1222        );
1223    } else {
1224        println!("Hybrid search for {:?}:", query);
1225        for (i, m) in results.iter().enumerate() {
1226            if let Some(node) = graph.get_node(m.node_id) {
1227                let preview = if node.content.len() > 60 {
1228                    format!("{}...", &node.content[..60])
1229                } else {
1230                    node.content.clone()
1231                };
1232                println!(
1233                    "  #{:<3} Node {} ({}) [score: {:.4}]  {:?}",
1234                    i + 1,
1235                    m.node_id,
1236                    node.event_type.name(),
1237                    m.combined_score,
1238                    preview
1239                );
1240            }
1241        }
1242        println!("  {} results", results.len());
1243    }
1244    Ok(())
1245}
1246
1247/// Centrality analysis.
1248#[allow(clippy::too_many_arguments)]
1249pub fn cmd_centrality(
1250    path: &Path,
1251    algorithm: &str,
1252    damping: f32,
1253    edge_types: Vec<EdgeType>,
1254    event_types: Vec<EventType>,
1255    limit: usize,
1256    iterations: u32,
1257    json: bool,
1258) -> AmemResult<()> {
1259    let graph = AmemReader::read_from_file(path)?;
1260    let query_engine = QueryEngine::new();
1261
1262    let algo = match algorithm {
1263        "degree" => CentralityAlgorithm::Degree,
1264        "betweenness" => CentralityAlgorithm::Betweenness,
1265        _ => CentralityAlgorithm::PageRank { damping },
1266    };
1267
1268    let result = query_engine.centrality(
1269        &graph,
1270        CentralityParams {
1271            algorithm: algo,
1272            max_iterations: iterations,
1273            tolerance: 1e-6,
1274            top_k: limit,
1275            event_types,
1276            edge_types,
1277        },
1278    )?;
1279
1280    if json {
1281        let scores: Vec<serde_json::Value> = result
1282            .scores
1283            .iter()
1284            .enumerate()
1285            .map(|(i, (id, score))| {
1286                let node = graph.get_node(*id);
1287                serde_json::json!({
1288                    "rank": i + 1,
1289                    "node_id": id,
1290                    "score": score,
1291                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1292                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1293                })
1294            })
1295            .collect();
1296        println!(
1297            "{}",
1298            serde_json::to_string_pretty(&serde_json::json!({
1299                "algorithm": algorithm,
1300                "converged": result.converged,
1301                "iterations": result.iterations,
1302                "scores": scores,
1303            }))
1304            .unwrap_or_default()
1305        );
1306    } else {
1307        let algo_name = match algorithm {
1308            "degree" => "Degree",
1309            "betweenness" => "Betweenness",
1310            _ => "PageRank",
1311        };
1312        println!(
1313            "{} centrality (converged: {}, iterations: {}):",
1314            algo_name, result.converged, result.iterations
1315        );
1316        for (i, (id, score)) in result.scores.iter().enumerate() {
1317            if let Some(node) = graph.get_node(*id) {
1318                let preview = if node.content.len() > 50 {
1319                    format!("{}...", &node.content[..50])
1320                } else {
1321                    node.content.clone()
1322                };
1323                println!(
1324                    "  #{:<3} Node {} ({}) [score: {:.6}]  {:?}",
1325                    i + 1,
1326                    id,
1327                    node.event_type.name(),
1328                    score,
1329                    preview
1330                );
1331            }
1332        }
1333    }
1334    Ok(())
1335}
1336
1337/// Shortest path.
1338#[allow(clippy::too_many_arguments)]
1339pub fn cmd_path(
1340    path: &Path,
1341    source_id: u64,
1342    target_id: u64,
1343    edge_types: Vec<EdgeType>,
1344    direction: TraversalDirection,
1345    max_depth: u32,
1346    weighted: bool,
1347    json: bool,
1348) -> AmemResult<()> {
1349    let graph = AmemReader::read_from_file(path)?;
1350    let query_engine = QueryEngine::new();
1351
1352    let result = query_engine.shortest_path(
1353        &graph,
1354        ShortestPathParams {
1355            source_id,
1356            target_id,
1357            edge_types,
1358            direction,
1359            max_depth,
1360            weighted,
1361        },
1362    )?;
1363
1364    if json {
1365        let path_info: Vec<serde_json::Value> = result
1366            .path
1367            .iter()
1368            .map(|&id| {
1369                let node = graph.get_node(id);
1370                serde_json::json!({
1371                    "node_id": id,
1372                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1373                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1374                })
1375            })
1376            .collect();
1377        let edges_info: Vec<serde_json::Value> = result
1378            .edges
1379            .iter()
1380            .map(|e| {
1381                serde_json::json!({
1382                    "source_id": e.source_id,
1383                    "target_id": e.target_id,
1384                    "edge_type": e.edge_type.name(),
1385                    "weight": e.weight,
1386                })
1387            })
1388            .collect();
1389        println!(
1390            "{}",
1391            serde_json::to_string_pretty(&serde_json::json!({
1392                "found": result.found,
1393                "cost": result.cost,
1394                "path": path_info,
1395                "edges": edges_info,
1396            }))
1397            .unwrap_or_default()
1398        );
1399    } else if result.found {
1400        println!(
1401            "Path from node {} to node {} ({} hops, cost: {:.2}):",
1402            source_id,
1403            target_id,
1404            result.path.len().saturating_sub(1),
1405            result.cost
1406        );
1407        // Print path as chain
1408        let mut parts: Vec<String> = Vec::new();
1409        for (i, &id) in result.path.iter().enumerate() {
1410            if let Some(node) = graph.get_node(id) {
1411                let label = format!("Node {} ({})", id, node.event_type.name());
1412                if i < result.edges.len() {
1413                    parts.push(format!(
1414                        "{} --[{}]-->",
1415                        label,
1416                        result.edges[i].edge_type.name()
1417                    ));
1418                } else {
1419                    parts.push(label);
1420                }
1421            }
1422        }
1423        println!("  {}", parts.join(" "));
1424    } else {
1425        println!(
1426            "No path found from node {} to node {}",
1427            source_id, target_id
1428        );
1429    }
1430    Ok(())
1431}
1432
1433/// Belief revision.
1434pub fn cmd_revise(
1435    path: &Path,
1436    hypothesis: &str,
1437    threshold: f32,
1438    max_depth: u32,
1439    confidence: f32,
1440    json: bool,
1441) -> AmemResult<()> {
1442    let graph = AmemReader::read_from_file(path)?;
1443    let query_engine = QueryEngine::new();
1444
1445    let report = query_engine.belief_revision(
1446        &graph,
1447        BeliefRevisionParams {
1448            hypothesis: hypothesis.to_string(),
1449            hypothesis_vec: None,
1450            contradiction_threshold: threshold,
1451            max_depth,
1452            hypothesis_confidence: confidence,
1453        },
1454    )?;
1455
1456    if json {
1457        let contradicted: Vec<serde_json::Value> = report
1458            .contradicted
1459            .iter()
1460            .map(|c| {
1461                let node = graph.get_node(c.node_id);
1462                serde_json::json!({
1463                    "node_id": c.node_id,
1464                    "strength": c.contradiction_strength,
1465                    "reason": c.reason,
1466                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1467                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1468                })
1469            })
1470            .collect();
1471        let weakened: Vec<serde_json::Value> = report
1472            .weakened
1473            .iter()
1474            .map(|w| {
1475                serde_json::json!({
1476                    "node_id": w.node_id,
1477                    "original_confidence": w.original_confidence,
1478                    "revised_confidence": w.revised_confidence,
1479                    "depth": w.depth,
1480                })
1481            })
1482            .collect();
1483        let cascade: Vec<serde_json::Value> = report
1484            .cascade
1485            .iter()
1486            .map(|s| {
1487                serde_json::json!({
1488                    "node_id": s.node_id,
1489                    "via_edge": s.via_edge.name(),
1490                    "from_node": s.from_node,
1491                    "depth": s.depth,
1492                })
1493            })
1494            .collect();
1495        println!(
1496            "{}",
1497            serde_json::to_string_pretty(&serde_json::json!({
1498                "hypothesis": hypothesis,
1499                "contradicted": contradicted,
1500                "weakened": weakened,
1501                "invalidated_decisions": report.invalidated_decisions,
1502                "total_affected": report.total_affected,
1503                "cascade": cascade,
1504            }))
1505            .unwrap_or_default()
1506        );
1507    } else {
1508        println!("Belief revision: {:?}\n", hypothesis);
1509        if report.contradicted.is_empty() {
1510            println!("  No contradictions found.");
1511        } else {
1512            println!("Directly contradicted:");
1513            for c in &report.contradicted {
1514                if let Some(node) = graph.get_node(c.node_id) {
1515                    println!(
1516                        "  X Node {} ({}): {:?} [score: {:.2}]",
1517                        c.node_id,
1518                        node.event_type.name(),
1519                        node.content,
1520                        c.contradiction_strength
1521                    );
1522                }
1523            }
1524        }
1525        if !report.weakened.is_empty() {
1526            println!("\nCascade effects:");
1527            for w in &report.weakened {
1528                if let Some(node) = graph.get_node(w.node_id) {
1529                    let action = if node.event_type == EventType::Decision {
1530                        "INVALIDATED"
1531                    } else {
1532                        "weakened"
1533                    };
1534                    println!(
1535                        "  ! Node {} ({}): {} ({:.2} -> {:.2})",
1536                        w.node_id,
1537                        node.event_type.name(),
1538                        action,
1539                        w.original_confidence,
1540                        w.revised_confidence
1541                    );
1542                }
1543            }
1544        }
1545        println!(
1546            "\nTotal affected: {} nodes ({} decisions)",
1547            report.total_affected,
1548            report.invalidated_decisions.len()
1549        );
1550    }
1551    Ok(())
1552}
1553
1554/// Gap detection.
1555#[allow(clippy::too_many_arguments)]
1556pub fn cmd_gaps(
1557    path: &Path,
1558    threshold: f32,
1559    min_support: u32,
1560    limit: usize,
1561    sort: &str,
1562    session_range: Option<(u32, u32)>,
1563    json: bool,
1564) -> AmemResult<()> {
1565    let graph = AmemReader::read_from_file(path)?;
1566    let query_engine = QueryEngine::new();
1567
1568    let sort_by = match sort {
1569        "recent" => GapSeverity::MostRecent,
1570        "confidence" => GapSeverity::LowestConfidence,
1571        _ => GapSeverity::HighestImpact,
1572    };
1573
1574    let report = query_engine.gap_detection(
1575        &graph,
1576        GapDetectionParams {
1577            confidence_threshold: threshold,
1578            min_support_count: min_support,
1579            max_results: limit,
1580            session_range,
1581            sort_by,
1582        },
1583    )?;
1584
1585    if json {
1586        let gaps: Vec<serde_json::Value> = report
1587            .gaps
1588            .iter()
1589            .map(|g| {
1590                let node = graph.get_node(g.node_id);
1591                serde_json::json!({
1592                    "node_id": g.node_id,
1593                    "gap_type": format!("{:?}", g.gap_type),
1594                    "severity": g.severity,
1595                    "description": g.description,
1596                    "downstream_count": g.downstream_count,
1597                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1598                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1599                })
1600            })
1601            .collect();
1602        println!(
1603            "{}",
1604            serde_json::to_string_pretty(&serde_json::json!({
1605                "gaps": gaps,
1606                "health_score": report.summary.health_score,
1607                "summary": {
1608                    "total_gaps": report.summary.total_gaps,
1609                    "unjustified_decisions": report.summary.unjustified_decisions,
1610                    "single_source_inferences": report.summary.single_source_inferences,
1611                    "low_confidence_foundations": report.summary.low_confidence_foundations,
1612                    "unstable_knowledge": report.summary.unstable_knowledge,
1613                    "stale_evidence": report.summary.stale_evidence,
1614                }
1615            }))
1616            .unwrap_or_default()
1617        );
1618    } else {
1619        println!("Reasoning gaps in {}:\n", path.display());
1620        for g in &report.gaps {
1621            let severity_marker = if g.severity > 0.8 {
1622                "CRITICAL"
1623            } else if g.severity > 0.5 {
1624                "WARNING"
1625            } else {
1626                "INFO"
1627            };
1628            if let Some(node) = graph.get_node(g.node_id) {
1629                println!(
1630                    "  {}: Node {} ({}) -- {:?}",
1631                    severity_marker,
1632                    g.node_id,
1633                    node.event_type.name(),
1634                    g.gap_type
1635                );
1636                let preview = if node.content.len() > 60 {
1637                    format!("{}...", &node.content[..60])
1638                } else {
1639                    node.content.clone()
1640                };
1641                println!("     {:?}", preview);
1642                println!(
1643                    "     Severity: {:.2} | {} downstream dependents",
1644                    g.severity, g.downstream_count
1645                );
1646                println!();
1647            }
1648        }
1649        println!(
1650            "Health score: {:.2} / 1.00  |  {} gaps found",
1651            report.summary.health_score, report.summary.total_gaps
1652        );
1653    }
1654    Ok(())
1655}
1656
1657/// Analogical query.
1658#[allow(clippy::too_many_arguments)]
1659pub fn cmd_analogy(
1660    path: &Path,
1661    description: &str,
1662    limit: usize,
1663    min_similarity: f32,
1664    exclude_sessions: Vec<u32>,
1665    depth: u32,
1666    json: bool,
1667) -> AmemResult<()> {
1668    let graph = AmemReader::read_from_file(path)?;
1669    let query_engine = QueryEngine::new();
1670
1671    // Find the best matching node to use as anchor
1672    let tokenizer = crate::engine::Tokenizer::new();
1673    let query_terms: std::collections::HashSet<String> =
1674        tokenizer.tokenize(description).into_iter().collect();
1675
1676    // Find the most relevant node as the anchor center
1677    let mut best_id = None;
1678    let mut best_score = -1.0f32;
1679    for node in graph.nodes() {
1680        let node_terms: std::collections::HashSet<String> =
1681            tokenizer.tokenize(&node.content).into_iter().collect();
1682        let overlap = query_terms.intersection(&node_terms).count();
1683        let score = if query_terms.is_empty() {
1684            0.0
1685        } else {
1686            overlap as f32 / query_terms.len() as f32
1687        };
1688        if score > best_score {
1689            best_score = score;
1690            best_id = Some(node.id);
1691        }
1692    }
1693
1694    let anchor = match best_id {
1695        Some(id) => AnalogicalAnchor::Node(id),
1696        None => {
1697            println!("No matching nodes found for the description.");
1698            return Ok(());
1699        }
1700    };
1701
1702    let results = query_engine.analogical(
1703        &graph,
1704        AnalogicalParams {
1705            anchor,
1706            context_depth: depth,
1707            max_results: limit,
1708            min_similarity,
1709            exclude_sessions,
1710        },
1711    )?;
1712
1713    if json {
1714        let analogies: Vec<serde_json::Value> = results
1715            .iter()
1716            .map(|a| {
1717                let node = graph.get_node(a.center_id);
1718                serde_json::json!({
1719                    "center_id": a.center_id,
1720                    "structural_similarity": a.structural_similarity,
1721                    "content_similarity": a.content_similarity,
1722                    "combined_score": a.combined_score,
1723                    "subgraph_nodes": a.subgraph_nodes,
1724                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1725                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1726                })
1727            })
1728            .collect();
1729        println!(
1730            "{}",
1731            serde_json::to_string_pretty(&serde_json::json!({
1732                "description": description,
1733                "analogies": analogies,
1734            }))
1735            .unwrap_or_default()
1736        );
1737    } else {
1738        println!("Analogies for {:?}:\n", description);
1739        for (i, a) in results.iter().enumerate() {
1740            if let Some(node) = graph.get_node(a.center_id) {
1741                println!(
1742                    "  #{} Node {} ({}) [combined: {:.3}]",
1743                    i + 1,
1744                    a.center_id,
1745                    node.event_type.name(),
1746                    a.combined_score
1747                );
1748                println!(
1749                    "     Structural: {:.3} | Content: {:.3} | Subgraph: {} nodes",
1750                    a.structural_similarity,
1751                    a.content_similarity,
1752                    a.subgraph_nodes.len()
1753                );
1754            }
1755        }
1756        if results.is_empty() {
1757            println!("  No analogies found.");
1758        }
1759    }
1760    Ok(())
1761}
1762
1763/// Consolidation.
1764#[allow(clippy::too_many_arguments)]
1765pub fn cmd_consolidate(
1766    path: &Path,
1767    deduplicate: bool,
1768    link_contradictions: bool,
1769    promote_inferences: bool,
1770    prune: bool,
1771    compress_episodes: bool,
1772    all: bool,
1773    threshold: f32,
1774    confirm: bool,
1775    backup: Option<std::path::PathBuf>,
1776    json: bool,
1777) -> AmemResult<()> {
1778    let mut graph = AmemReader::read_from_file(path)?;
1779    let query_engine = QueryEngine::new();
1780
1781    let dry_run = !confirm;
1782
1783    // Build operations list
1784    let mut ops = Vec::new();
1785    if deduplicate || all {
1786        ops.push(ConsolidationOp::DeduplicateFacts { threshold });
1787    }
1788    if link_contradictions || all {
1789        ops.push(ConsolidationOp::LinkContradictions {
1790            threshold: threshold.min(0.8),
1791        });
1792    }
1793    if promote_inferences || all {
1794        ops.push(ConsolidationOp::PromoteInferences {
1795            min_access: 3,
1796            min_confidence: 0.8,
1797        });
1798    }
1799    if prune || all {
1800        ops.push(ConsolidationOp::PruneOrphans { max_decay: 0.1 });
1801    }
1802    if compress_episodes || all {
1803        ops.push(ConsolidationOp::CompressEpisodes { group_size: 3 });
1804    }
1805
1806    if ops.is_empty() {
1807        eprintln!("No operations specified. Use --deduplicate, --link-contradictions, --promote-inferences, --prune, --compress-episodes, or --all");
1808        return Ok(());
1809    }
1810
1811    // If not dry-run, create backup first
1812    let backup_path = if !dry_run {
1813        let bp = backup.unwrap_or_else(|| {
1814            let mut p = path.to_path_buf();
1815            let name = p
1816                .file_stem()
1817                .unwrap_or_default()
1818                .to_string_lossy()
1819                .to_string();
1820            p.set_file_name(format!("{}.pre-consolidation.amem", name));
1821            p
1822        });
1823        std::fs::copy(path, &bp)?;
1824        Some(bp)
1825    } else {
1826        None
1827    };
1828
1829    let report = query_engine.consolidate(
1830        &mut graph,
1831        ConsolidationParams {
1832            session_range: None,
1833            operations: ops,
1834            dry_run,
1835            backup_path: backup_path.clone(),
1836        },
1837    )?;
1838
1839    // Write back if not dry-run
1840    if !dry_run {
1841        let writer = AmemWriter::new(graph.dimension());
1842        writer.write_to_file(&graph, path)?;
1843    }
1844
1845    if json {
1846        let actions: Vec<serde_json::Value> = report
1847            .actions
1848            .iter()
1849            .map(|a| {
1850                serde_json::json!({
1851                    "operation": a.operation,
1852                    "description": a.description,
1853                    "affected_nodes": a.affected_nodes,
1854                })
1855            })
1856            .collect();
1857        println!(
1858            "{}",
1859            serde_json::to_string_pretty(&serde_json::json!({
1860                "dry_run": dry_run,
1861                "deduplicated": report.deduplicated,
1862                "contradictions_linked": report.contradictions_linked,
1863                "inferences_promoted": report.inferences_promoted,
1864                "backup_path": backup_path.map(|p| p.display().to_string()),
1865                "actions": actions,
1866            }))
1867            .unwrap_or_default()
1868        );
1869    } else {
1870        if dry_run {
1871            println!("Consolidation DRY RUN (use --confirm to apply):\n");
1872        } else {
1873            println!("Consolidation applied:\n");
1874            if let Some(bp) = &backup_path {
1875                println!("  Backup: {}", bp.display());
1876            }
1877        }
1878        for a in &report.actions {
1879            println!("  [{}] {}", a.operation, a.description);
1880        }
1881        println!();
1882        println!("  Deduplicated: {}", report.deduplicated);
1883        println!("  Contradictions linked: {}", report.contradictions_linked);
1884        println!("  Inferences promoted: {}", report.inferences_promoted);
1885    }
1886    Ok(())
1887}
1888
1889/// Drift detection.
1890pub fn cmd_drift(
1891    path: &Path,
1892    topic: &str,
1893    limit: usize,
1894    min_relevance: f32,
1895    json: bool,
1896) -> AmemResult<()> {
1897    let graph = AmemReader::read_from_file(path)?;
1898    let query_engine = QueryEngine::new();
1899
1900    let report = query_engine.drift_detection(
1901        &graph,
1902        DriftParams {
1903            topic: topic.to_string(),
1904            topic_vec: None,
1905            max_results: limit,
1906            min_relevance,
1907        },
1908    )?;
1909
1910    if json {
1911        let timelines: Vec<serde_json::Value> = report
1912            .timelines
1913            .iter()
1914            .map(|t| {
1915                let snapshots: Vec<serde_json::Value> = t
1916                    .snapshots
1917                    .iter()
1918                    .map(|s| {
1919                        serde_json::json!({
1920                            "node_id": s.node_id,
1921                            "session_id": s.session_id,
1922                            "confidence": s.confidence,
1923                            "content_preview": s.content_preview,
1924                            "change_type": format!("{:?}", s.change_type),
1925                        })
1926                    })
1927                    .collect();
1928                serde_json::json!({
1929                    "snapshots": snapshots,
1930                    "change_count": t.change_count,
1931                    "correction_count": t.correction_count,
1932                    "contradiction_count": t.contradiction_count,
1933                })
1934            })
1935            .collect();
1936        println!(
1937            "{}",
1938            serde_json::to_string_pretty(&serde_json::json!({
1939                "topic": topic,
1940                "timelines": timelines,
1941                "stability": report.stability,
1942                "likely_to_change": report.likely_to_change,
1943            }))
1944            .unwrap_or_default()
1945        );
1946    } else {
1947        println!("Drift analysis for {:?}:\n", topic);
1948        for (i, t) in report.timelines.iter().enumerate() {
1949            println!(
1950                "Timeline {} ({} changes, stability: {:.1}):",
1951                i + 1,
1952                t.change_count,
1953                report.stability
1954            );
1955            for s in &t.snapshots {
1956                let change = format!("{:?}", s.change_type).to_uppercase();
1957                println!(
1958                    "  Session {:>3}: {:<12} {:?}  [{:.2}]",
1959                    s.session_id, change, s.content_preview, s.confidence
1960                );
1961            }
1962            println!();
1963        }
1964        if report.timelines.is_empty() {
1965            println!("  No relevant nodes found for this topic.");
1966        } else {
1967            let prediction = if report.likely_to_change {
1968                "LIKELY TO CHANGE"
1969            } else {
1970                "STABLE"
1971            };
1972            println!(
1973                "Overall stability: {:.2} | Prediction: {}",
1974                report.stability, prediction
1975            );
1976        }
1977    }
1978    Ok(())
1979}