use std::future::Future;
use std::pin::Pin;
use crate::context::CommandContext;
use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
pub struct MemoryCommand;
impl CommandHandler<CommandContext<'_>> for MemoryCommand {
fn name(&self) -> &'static str {
"/memory"
}
fn description(&self) -> &'static str {
"Show memory tier stats or manually promote messages to semantic tier"
}
fn args_hint(&self) -> &'static str {
"[tiers|promote <id>...]"
}
fn category(&self) -> SlashCategory {
SlashCategory::Memory
}
fn requires_auth(&self) -> bool {
true
}
fn handle<'a>(
&'a self,
ctx: &'a mut CommandContext<'_>,
args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
use tracing::Instrument as _;
let span = tracing::info_span!("commands.memory.handle");
Box::pin(
async move {
let result = if args.is_empty() || args == "tiers" {
ctx.agent.memory_tiers().await?
} else if let Some(rest) = args.strip_prefix("promote") {
ctx.agent.memory_promote(rest.trim()).await?
} else {
"Unknown /memory subcommand. Available: /memory tiers, /memory promote <id>..."
.to_owned()
};
Ok(CommandOutput::Message(result))
}
.instrument(span),
)
}
}
pub struct GraphCommand;
impl CommandHandler<CommandContext<'_>> for GraphCommand {
fn name(&self) -> &'static str {
"/graph"
}
fn description(&self) -> &'static str {
"Query or manage the knowledge graph"
}
fn args_hint(&self) -> &'static str {
"[subcommand]"
}
fn category(&self) -> SlashCategory {
SlashCategory::Memory
}
fn requires_auth(&self) -> bool {
true
}
fn handle<'a>(
&'a self,
ctx: &'a mut CommandContext<'_>,
args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
use tracing::Instrument as _;
let span = tracing::info_span!("commands.graph.handle");
Box::pin(
async move {
let result = if args.is_empty() {
ctx.agent.graph_stats().await?
} else if args == "entities" || args.starts_with("entities ") {
ctx.agent.graph_entities().await?
} else if let Some(name) = args.strip_prefix("facts ") {
ctx.agent.graph_facts(name.trim()).await?
} else if args == "communities" {
ctx.agent.graph_communities().await?
} else if args == "backfill" || args.starts_with("backfill ") {
let limit = parse_backfill_limit(args);
let mut progress_messages: Vec<String> = Vec::new();
let final_msg = ctx
.agent
.graph_backfill(limit, &mut |msg| progress_messages.push(msg))
.await?;
for msg in &progress_messages {
ctx.sink.send(msg).await?;
}
final_msg
} else if let Some(name) = args.strip_prefix("history ") {
ctx.agent.graph_history(name.trim()).await?
} else {
"Unknown /graph subcommand. Available: /graph, /graph entities, \
/graph facts <name>, /graph history <name>, /graph communities, \
/graph backfill [--limit N]"
.to_owned()
};
Ok(CommandOutput::Message(result))
}
.instrument(span),
)
}
}
pub struct GuidelinesCommand;
impl CommandHandler<CommandContext<'_>> for GuidelinesCommand {
fn name(&self) -> &'static str {
"/guidelines"
}
fn description(&self) -> &'static str {
"Show current compression guidelines"
}
fn category(&self) -> SlashCategory {
SlashCategory::Memory
}
fn feature_gate(&self) -> Option<&'static str> {
Some("compression-guidelines")
}
fn requires_auth(&self) -> bool {
false
}
fn handle<'a>(
&'a self,
ctx: &'a mut CommandContext<'_>,
_args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
use tracing::Instrument as _;
let span = tracing::info_span!("commands.guidelines.handle");
Box::pin(
async move {
let result = ctx.agent.guidelines().await?;
Ok(CommandOutput::Message(result))
}
.instrument(span),
)
}
}
pub struct KnowledgeSlashCommand;
impl CommandHandler<CommandContext<'_>> for KnowledgeSlashCommand {
fn name(&self) -> &'static str {
"/knowledge"
}
fn description(&self) -> &'static str {
"Query the knowledge ingest ledger or roll back a batch"
}
fn args_hint(&self) -> &'static str {
"[status | rollback <batch_id>]"
}
fn category(&self) -> SlashCategory {
SlashCategory::Memory
}
fn requires_auth(&self) -> bool {
true
}
fn handle<'a>(
&'a self,
ctx: &'a mut CommandContext<'_>,
args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
use tracing::Instrument as _;
let span = tracing::info_span!("commands.knowledge.handle");
Box::pin(
async move {
let result = if args.is_empty() || args == "status" {
ctx.agent.knowledge_status().await?
} else if let Some(batch_id) = args.strip_prefix("rollback ") {
ctx.agent.knowledge_rollback(batch_id.trim()).await?
} else {
"Unknown /knowledge subcommand. Usage: /knowledge [status | rollback <batch_id>]"
.to_owned()
};
Ok(CommandOutput::Message(result))
}
.instrument(span),
)
}
}
pub struct StoreSlashCommand;
impl CommandHandler<CommandContext<'_>> for StoreSlashCommand {
fn name(&self) -> &'static str {
"/store"
}
fn description(&self) -> &'static str {
"Read/write the cross-thread key-value store"
}
fn args_hint(&self) -> &'static str {
"get <ns> <key> | put <ns> <key> <value...> | list <ns_prefix> [limit] | delete <ns> <key>"
}
fn category(&self) -> SlashCategory {
SlashCategory::Memory
}
fn requires_auth(&self) -> bool {
true
}
fn handle<'a>(
&'a self,
ctx: &'a mut CommandContext<'_>,
args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
use tracing::Instrument as _;
let span = tracing::info_span!("commands.store.handle");
Box::pin(
async move {
let result = ctx.agent.store_command(args).await?;
Ok(CommandOutput::Message(result))
}
.instrument(span),
)
}
}
fn parse_backfill_limit(args: &str) -> Option<usize> {
let pos = args.find("--limit")?;
args[pos + "--limit".len()..]
.split_whitespace()
.next()
.and_then(|s| s.parse::<usize>().ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CommandRegistry;
use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
use crate::sink::NullSink;
#[test]
fn backfill_limit_parsing() {
assert_eq!(parse_backfill_limit("backfill --limit 100"), Some(100));
assert_eq!(parse_backfill_limit("backfill"), None);
assert_eq!(parse_backfill_limit("backfill --limit"), None);
assert_eq!(parse_backfill_limit("backfill --limit 0"), Some(0));
}
#[test]
fn guidelines_requires_auth_false() {
assert!(!GuidelinesCommand.requires_auth());
}
#[tokio::test]
async fn memory_dispatch_allowed_when_trusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(MemoryCommand);
let result = reg.dispatch(&mut ctx, "/memory tiers", true).await;
assert!(result.unwrap().is_ok());
}
#[tokio::test]
async fn memory_dispatch_rejected_when_untrusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(MemoryCommand);
let result = reg.dispatch(&mut ctx, "/memory tiers", false).await;
let err = result.unwrap().unwrap_err();
assert!(err.0.contains("trusted"));
}
#[tokio::test]
async fn graph_dispatch_allowed_when_trusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(GraphCommand);
let result = reg.dispatch(&mut ctx, "/graph", true).await;
assert!(result.unwrap().is_ok());
}
#[tokio::test]
async fn graph_dispatch_rejected_when_untrusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(GraphCommand);
let result = reg.dispatch(&mut ctx, "/graph", false).await;
let err = result.unwrap().unwrap_err();
assert!(err.0.contains("trusted"));
}
#[tokio::test]
async fn knowledge_dispatch_allowed_when_trusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(KnowledgeSlashCommand);
let result = reg.dispatch(&mut ctx, "/knowledge status", true).await;
assert!(result.unwrap().is_ok());
}
#[tokio::test]
async fn knowledge_dispatch_rejected_when_untrusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(KnowledgeSlashCommand);
let result = reg.dispatch(&mut ctx, "/knowledge status", false).await;
let err = result.unwrap().unwrap_err();
assert!(err.0.contains("trusted"));
}
#[tokio::test]
async fn store_dispatch_allowed_when_trusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(StoreSlashCommand);
let result = reg.dispatch(&mut ctx, "/store list orch/", true).await;
assert!(result.unwrap().is_ok());
}
#[tokio::test]
async fn store_dispatch_rejected_when_untrusted() {
let mut sink = NullSink;
let mut debug = MockDebug;
let mut messages = MockMessages;
let session = MockSession;
let mut agent = crate::NullAgent;
let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
reg.register(StoreSlashCommand);
let result = reg.dispatch(&mut ctx, "/store list orch/", false).await;
let err = result.unwrap().unwrap_err();
assert!(err.0.contains("trusted"));
}
}