Skip to main content

zeph_commands/handlers/
memory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Memory command handlers: `/memory`, `/graph`, `/guidelines`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// Display memory tier statistics or promote messages to the semantic tier.
13///
14/// Subcommands: (none or `tiers`) shows stats; `promote <id>...` promotes messages.
15pub struct MemoryCommand;
16
17impl CommandHandler<CommandContext<'_>> for MemoryCommand {
18    fn name(&self) -> &'static str {
19        "/memory"
20    }
21
22    fn description(&self) -> &'static str {
23        "Show memory tier stats or manually promote messages to semantic tier"
24    }
25
26    fn args_hint(&self) -> &'static str {
27        "[tiers|promote <id>...]"
28    }
29
30    fn category(&self) -> SlashCategory {
31        SlashCategory::Memory
32    }
33
34    fn requires_auth(&self) -> bool {
35        true
36    }
37
38    fn handle<'a>(
39        &'a self,
40        ctx: &'a mut CommandContext<'_>,
41        args: &'a str,
42    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
43        use tracing::Instrument as _;
44        let span = tracing::info_span!("commands.memory.handle");
45        Box::pin(
46            async move {
47                let result = if args.is_empty() || args == "tiers" {
48                    ctx.agent.memory_tiers().await?
49                } else if let Some(rest) = args.strip_prefix("promote") {
50                    ctx.agent.memory_promote(rest.trim()).await?
51                } else {
52                    "Unknown /memory subcommand. Available: /memory tiers, /memory promote <id>..."
53                        .to_owned()
54                };
55                Ok(CommandOutput::Message(result))
56            }
57            .instrument(span),
58        )
59    }
60}
61
62/// Query and manage the knowledge graph.
63///
64/// Subcommands: (none) stats; `entities`; `facts <name>`; `history <name>`;
65/// `communities`; `backfill [--limit N]`.
66pub struct GraphCommand;
67
68impl CommandHandler<CommandContext<'_>> for GraphCommand {
69    fn name(&self) -> &'static str {
70        "/graph"
71    }
72
73    fn description(&self) -> &'static str {
74        "Query or manage the knowledge graph"
75    }
76
77    fn args_hint(&self) -> &'static str {
78        "[subcommand]"
79    }
80
81    fn category(&self) -> SlashCategory {
82        SlashCategory::Memory
83    }
84
85    fn requires_auth(&self) -> bool {
86        true
87    }
88
89    fn handle<'a>(
90        &'a self,
91        ctx: &'a mut CommandContext<'_>,
92        args: &'a str,
93    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
94        use tracing::Instrument as _;
95        let span = tracing::info_span!("commands.graph.handle");
96        Box::pin(
97            async move {
98                let result = if args.is_empty() {
99                    ctx.agent.graph_stats().await?
100                } else if args == "entities" || args.starts_with("entities ") {
101                    ctx.agent.graph_entities().await?
102                } else if let Some(name) = args.strip_prefix("facts ") {
103                    ctx.agent.graph_facts(name.trim()).await?
104                } else if args == "communities" {
105                    ctx.agent.graph_communities().await?
106                } else if args == "backfill" || args.starts_with("backfill ") {
107                    let limit = parse_backfill_limit(args);
108                    let mut progress_messages: Vec<String> = Vec::new();
109                    let final_msg = ctx
110                        .agent
111                        .graph_backfill(limit, &mut |msg| progress_messages.push(msg))
112                        .await?;
113                    for msg in &progress_messages {
114                        ctx.sink.send(msg).await?;
115                    }
116                    final_msg
117                } else if let Some(name) = args.strip_prefix("history ") {
118                    ctx.agent.graph_history(name.trim()).await?
119                } else {
120                    "Unknown /graph subcommand. Available: /graph, /graph entities, \
121                 /graph facts <name>, /graph history <name>, /graph communities, \
122                 /graph backfill [--limit N]"
123                        .to_owned()
124                };
125                Ok(CommandOutput::Message(result))
126            }
127            .instrument(span),
128        )
129    }
130}
131
132/// Show current compression guidelines.
133pub struct GuidelinesCommand;
134
135impl CommandHandler<CommandContext<'_>> for GuidelinesCommand {
136    fn name(&self) -> &'static str {
137        "/guidelines"
138    }
139
140    fn description(&self) -> &'static str {
141        "Show current compression guidelines"
142    }
143
144    fn category(&self) -> SlashCategory {
145        SlashCategory::Memory
146    }
147
148    fn feature_gate(&self) -> Option<&'static str> {
149        Some("compression-guidelines")
150    }
151
152    fn requires_auth(&self) -> bool {
153        false
154    }
155
156    fn handle<'a>(
157        &'a self,
158        ctx: &'a mut CommandContext<'_>,
159        _args: &'a str,
160    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
161        use tracing::Instrument as _;
162        let span = tracing::info_span!("commands.guidelines.handle");
163        Box::pin(
164            async move {
165                let result = ctx.agent.guidelines().await?;
166                Ok(CommandOutput::Message(result))
167            }
168            .instrument(span),
169        )
170    }
171}
172
173/// Query the knowledge ingest ledger or roll back a batch.
174///
175/// Subcommands:
176/// - (none) or `status` — list batches and ledger summary
177/// - `rollback <batch_id>` — delete a batch (no confirmation in slash-command context)
178pub struct KnowledgeSlashCommand;
179
180impl CommandHandler<CommandContext<'_>> for KnowledgeSlashCommand {
181    fn name(&self) -> &'static str {
182        "/knowledge"
183    }
184
185    fn description(&self) -> &'static str {
186        "Query the knowledge ingest ledger or roll back a batch"
187    }
188
189    fn args_hint(&self) -> &'static str {
190        "[status | rollback <batch_id>]"
191    }
192
193    fn category(&self) -> SlashCategory {
194        SlashCategory::Memory
195    }
196
197    fn requires_auth(&self) -> bool {
198        true
199    }
200
201    fn handle<'a>(
202        &'a self,
203        ctx: &'a mut CommandContext<'_>,
204        args: &'a str,
205    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
206        use tracing::Instrument as _;
207        let span = tracing::info_span!("commands.knowledge.handle");
208        Box::pin(
209            async move {
210                let result = if args.is_empty() || args == "status" {
211                    ctx.agent.knowledge_status().await?
212                } else if let Some(batch_id) = args.strip_prefix("rollback ") {
213                    ctx.agent.knowledge_rollback(batch_id.trim()).await?
214                } else {
215                    "Unknown /knowledge subcommand. Usage: /knowledge [status | rollback <batch_id>]"
216                        .to_owned()
217                };
218                Ok(CommandOutput::Message(result))
219            }
220            .instrument(span),
221        )
222    }
223}
224
225/// Read/write the cross-thread key-value store (spec-080, #6363).
226///
227/// Subcommands: `get <ns> <key>`; `put <ns> <key> <value...>`; `list <ns_prefix> [limit]`;
228/// `delete <ns> <key>`. Disabled (`[memory.store].enabled = false`, the default) returns an
229/// informational message, not an error.
230pub struct StoreSlashCommand;
231
232impl CommandHandler<CommandContext<'_>> for StoreSlashCommand {
233    fn name(&self) -> &'static str {
234        "/store"
235    }
236
237    fn description(&self) -> &'static str {
238        "Read/write the cross-thread key-value store"
239    }
240
241    fn args_hint(&self) -> &'static str {
242        "get <ns> <key> | put <ns> <key> <value...> | list <ns_prefix> [limit] | delete <ns> <key>"
243    }
244
245    fn category(&self) -> SlashCategory {
246        SlashCategory::Memory
247    }
248
249    fn requires_auth(&self) -> bool {
250        true
251    }
252
253    fn handle<'a>(
254        &'a self,
255        ctx: &'a mut CommandContext<'_>,
256        args: &'a str,
257    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
258        use tracing::Instrument as _;
259        let span = tracing::info_span!("commands.store.handle");
260        Box::pin(
261            async move {
262                let result = ctx.agent.store_command(args).await?;
263                Ok(CommandOutput::Message(result))
264            }
265            .instrument(span),
266        )
267    }
268}
269
270fn parse_backfill_limit(args: &str) -> Option<usize> {
271    let pos = args.find("--limit")?;
272    args[pos + "--limit".len()..]
273        .split_whitespace()
274        .next()
275        .and_then(|s| s.parse::<usize>().ok())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::CommandRegistry;
282    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
283    use crate::sink::NullSink;
284
285    #[test]
286    fn backfill_limit_parsing() {
287        assert_eq!(parse_backfill_limit("backfill --limit 100"), Some(100));
288        assert_eq!(parse_backfill_limit("backfill"), None);
289        assert_eq!(parse_backfill_limit("backfill --limit"), None);
290        assert_eq!(parse_backfill_limit("backfill --limit 0"), Some(0));
291    }
292
293    #[test]
294    fn guidelines_requires_auth_false() {
295        assert!(!GuidelinesCommand.requires_auth());
296    }
297
298    #[tokio::test]
299    async fn memory_dispatch_allowed_when_trusted() {
300        let mut sink = NullSink;
301        let mut debug = MockDebug;
302        let mut messages = MockMessages;
303        let session = MockSession;
304        let mut agent = crate::NullAgent;
305        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
306
307        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
308        reg.register(MemoryCommand);
309
310        let result = reg.dispatch(&mut ctx, "/memory tiers", true).await;
311        assert!(result.unwrap().is_ok());
312    }
313
314    #[tokio::test]
315    async fn memory_dispatch_rejected_when_untrusted() {
316        let mut sink = NullSink;
317        let mut debug = MockDebug;
318        let mut messages = MockMessages;
319        let session = MockSession;
320        let mut agent = crate::NullAgent;
321        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
322
323        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
324        reg.register(MemoryCommand);
325
326        let result = reg.dispatch(&mut ctx, "/memory tiers", false).await;
327        let err = result.unwrap().unwrap_err();
328        assert!(err.0.contains("trusted"));
329    }
330
331    #[tokio::test]
332    async fn graph_dispatch_allowed_when_trusted() {
333        let mut sink = NullSink;
334        let mut debug = MockDebug;
335        let mut messages = MockMessages;
336        let session = MockSession;
337        let mut agent = crate::NullAgent;
338        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
339
340        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
341        reg.register(GraphCommand);
342
343        let result = reg.dispatch(&mut ctx, "/graph", true).await;
344        assert!(result.unwrap().is_ok());
345    }
346
347    #[tokio::test]
348    async fn graph_dispatch_rejected_when_untrusted() {
349        let mut sink = NullSink;
350        let mut debug = MockDebug;
351        let mut messages = MockMessages;
352        let session = MockSession;
353        let mut agent = crate::NullAgent;
354        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
355
356        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
357        reg.register(GraphCommand);
358
359        let result = reg.dispatch(&mut ctx, "/graph", false).await;
360        let err = result.unwrap().unwrap_err();
361        assert!(err.0.contains("trusted"));
362    }
363
364    #[tokio::test]
365    async fn knowledge_dispatch_allowed_when_trusted() {
366        let mut sink = NullSink;
367        let mut debug = MockDebug;
368        let mut messages = MockMessages;
369        let session = MockSession;
370        let mut agent = crate::NullAgent;
371        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
372
373        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
374        reg.register(KnowledgeSlashCommand);
375
376        let result = reg.dispatch(&mut ctx, "/knowledge status", true).await;
377        assert!(result.unwrap().is_ok());
378    }
379
380    #[tokio::test]
381    async fn knowledge_dispatch_rejected_when_untrusted() {
382        let mut sink = NullSink;
383        let mut debug = MockDebug;
384        let mut messages = MockMessages;
385        let session = MockSession;
386        let mut agent = crate::NullAgent;
387        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
388
389        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
390        reg.register(KnowledgeSlashCommand);
391
392        let result = reg.dispatch(&mut ctx, "/knowledge status", false).await;
393        let err = result.unwrap().unwrap_err();
394        assert!(err.0.contains("trusted"));
395    }
396
397    #[tokio::test]
398    async fn store_dispatch_allowed_when_trusted() {
399        let mut sink = NullSink;
400        let mut debug = MockDebug;
401        let mut messages = MockMessages;
402        let session = MockSession;
403        let mut agent = crate::NullAgent;
404        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
405
406        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
407        reg.register(StoreSlashCommand);
408
409        let result = reg.dispatch(&mut ctx, "/store list orch/", true).await;
410        assert!(result.unwrap().is_ok());
411    }
412
413    #[tokio::test]
414    async fn store_dispatch_rejected_when_untrusted() {
415        let mut sink = NullSink;
416        let mut debug = MockDebug;
417        let mut messages = MockMessages;
418        let session = MockSession;
419        let mut agent = crate::NullAgent;
420        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
421
422        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
423        reg.register(StoreSlashCommand);
424
425        let result = reg.dispatch(&mut ctx, "/store list orch/", false).await;
426        let err = result.unwrap().unwrap_err();
427        assert!(err.0.contains("trusted"));
428    }
429}