zeph-commands 0.22.1

Slash command registry, handler trait, and channel sink abstraction for Zeph
Documentation
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Memory command handlers: `/memory`, `/graph`, `/guidelines`.

use std::future::Future;
use std::pin::Pin;

use crate::context::CommandContext;
use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};

/// Display memory tier statistics or promote messages to the semantic tier.
///
/// Subcommands: (none or `tiers`) shows stats; `promote <id>...` promotes messages.
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),
        )
    }
}

/// Query and manage the knowledge graph.
///
/// Subcommands: (none) stats; `entities`; `facts <name>`; `history <name>`;
/// `communities`; `backfill [--limit N]`.
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),
        )
    }
}

/// Show current compression guidelines.
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),
        )
    }
}

/// Query the knowledge ingest ledger or roll back a batch.
///
/// Subcommands:
/// - (none) or `status` — list batches and ledger summary
/// - `rollback <batch_id>` — delete a batch (no confirmation in slash-command context)
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),
        )
    }
}

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"));
    }
}