1use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12pub 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
62pub 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
132pub 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
173pub 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
225pub 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}