Skip to main content

agentic_memory/cli/
repl_complete.rs

1//! Tab completion for the amem interactive REPL.
2
3use rustyline::completion::{Completer, Pair};
4use rustyline::highlight::Highlighter;
5use rustyline::hint::Hinter;
6use rustyline::validate::Validator;
7use rustyline::{
8    Cmd, ConditionalEventHandler, Event, EventContext, EventHandler, Helper, KeyEvent, RepeatCount,
9};
10
11/// All available REPL slash commands.
12pub const COMMANDS: &[(&str, &str)] = &[
13    ("/create", "Create a new .amem file"),
14    ("/info", "Display info about loaded .amem file"),
15    ("/load", "Load an .amem file for querying"),
16    ("/add", "Add a cognitive event to the graph"),
17    ("/get", "Get a node by ID"),
18    ("/search", "Search nodes by pattern"),
19    ("/text-search", "BM25 text search over contents"),
20    ("/traverse", "Run a traversal query"),
21    ("/impact", "Causal impact analysis"),
22    ("/centrality", "Compute node importance"),
23    ("/path", "Find shortest path between nodes"),
24    ("/gaps", "Reasoning gap detection"),
25    ("/stats", "Graph statistics"),
26    ("/sessions", "List sessions"),
27    ("/clear", "Clear the screen"),
28    ("/help", "Show available commands"),
29    ("/exit", "Quit the REPL"),
30];
31
32/// Event types for completion.
33pub const EVENT_TYPES: &[&str] = &[
34    "fact",
35    "decision",
36    "inference",
37    "correction",
38    "skill",
39    "episode",
40];
41
42/// amem REPL helper providing tab completion.
43pub struct AmemHelper;
44
45impl Default for AmemHelper {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl AmemHelper {
52    pub fn new() -> Self {
53        Self
54    }
55
56    /// Get list of .amem files in the current directory.
57    fn amem_files(&self) -> Vec<String> {
58        let mut files = Vec::new();
59        if let Ok(entries) = std::fs::read_dir(".") {
60            for entry in entries.flatten() {
61                let path = entry.path();
62                if path.extension().is_some_and(|e| e == "amem") {
63                    if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
64                        files.push(name.to_string());
65                    }
66                }
67            }
68        }
69        files.sort();
70        files
71    }
72}
73
74impl Completer for AmemHelper {
75    type Candidate = Pair;
76
77    fn complete(
78        &self,
79        line: &str,
80        pos: usize,
81        _ctx: &rustyline::Context<'_>,
82    ) -> rustyline::Result<(usize, Vec<Pair>)> {
83        let input = &line[..pos];
84
85        // Complete command names
86        if !input.contains(' ') {
87            let matches: Vec<Pair> = COMMANDS
88                .iter()
89                .filter(|(cmd, _)| cmd.starts_with(input))
90                .map(|(cmd, desc)| Pair {
91                    display: format!("{cmd:<18} {desc}"),
92                    replacement: format!("{cmd} "),
93                })
94                .collect();
95            return Ok((0, matches));
96        }
97
98        let parts: Vec<&str> = input.splitn(2, ' ').collect();
99        let cmd = parts[0];
100        let args = if parts.len() > 1 { parts[1] } else { "" };
101
102        match cmd {
103            "/load" | "/info" | "/create" => {
104                let files = self.amem_files();
105                let prefix_start = input.len() - args.len();
106                let matches: Vec<Pair> = files
107                    .iter()
108                    .filter(|f| f.starts_with(args.trim()))
109                    .map(|f| Pair {
110                        display: f.clone(),
111                        replacement: format!("{f} "),
112                    })
113                    .collect();
114                Ok((prefix_start, matches))
115            }
116
117            "/add" => {
118                if !args.contains(' ') {
119                    let prefix_start = input.len() - args.len();
120                    let matches: Vec<Pair> = EVENT_TYPES
121                        .iter()
122                        .filter(|t| t.starts_with(args.trim()))
123                        .map(|t| Pair {
124                            display: t.to_string(),
125                            replacement: format!("{t} "),
126                        })
127                        .collect();
128                    return Ok((prefix_start, matches));
129                }
130                Ok((pos, Vec::new()))
131            }
132
133            _ => Ok((pos, Vec::new())),
134        }
135    }
136}
137
138impl Hinter for AmemHelper {
139    type Hint = String;
140
141    fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
142        if pos < line.len() || line.is_empty() {
143            return None;
144        }
145        if line.starts_with('/') && !line.contains(' ') {
146            for (cmd, _) in COMMANDS {
147                if cmd.starts_with(line) && *cmd != line {
148                    return Some(cmd[line.len()..].to_string());
149                }
150            }
151        }
152        None
153    }
154}
155
156impl Highlighter for AmemHelper {}
157impl Validator for AmemHelper {}
158impl Helper for AmemHelper {}
159
160/// Tab accepts hint if present, else triggers completion.
161pub struct TabCompleteOrAcceptHint;
162
163impl ConditionalEventHandler for TabCompleteOrAcceptHint {
164    fn handle(
165        &self,
166        _evt: &Event,
167        _n: RepeatCount,
168        _positive: bool,
169        ctx: &EventContext<'_>,
170    ) -> Option<Cmd> {
171        if ctx.has_hint() {
172            Some(Cmd::CompleteHint)
173        } else {
174            Some(Cmd::Complete)
175        }
176    }
177}
178
179/// Bind custom key sequences.
180pub fn bind_keys(rl: &mut rustyline::Editor<AmemHelper, rustyline::history::DefaultHistory>) {
181    rl.bind_sequence(
182        KeyEvent::from('\t'),
183        EventHandler::Conditional(Box::new(TabCompleteOrAcceptHint)),
184    );
185}
186
187/// Find closest matching command (Levenshtein).
188pub fn suggest_command(input: &str) -> Option<&'static str> {
189    let input_lower = input.to_lowercase();
190    let mut best: Option<(&str, usize)> = None;
191
192    for (cmd, _) in COMMANDS {
193        let cmd_name = &cmd[1..];
194        let dist = levenshtein(&input_lower, cmd_name);
195        if dist <= 3 && (best.is_none() || dist < best.unwrap().1) {
196            best = Some((cmd, dist));
197        }
198    }
199
200    best.map(|(cmd, _)| cmd)
201}
202
203fn levenshtein(a: &str, b: &str) -> usize {
204    let a_len = a.len();
205    let b_len = b.len();
206    if a_len == 0 {
207        return b_len;
208    }
209    if b_len == 0 {
210        return a_len;
211    }
212    let mut prev: Vec<usize> = (0..=b_len).collect();
213    let mut curr = vec![0; b_len + 1];
214    for (i, ca) in a.chars().enumerate() {
215        curr[0] = i + 1;
216        for (j, cb) in b.chars().enumerate() {
217            let cost = if ca == cb { 0 } else { 1 };
218            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
219        }
220        std::mem::swap(&mut prev, &mut curr);
221    }
222    prev[b_len]
223}