1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SlashCategory {
11 Session,
12 Model,
13 Info,
14 Memory,
15 Tools,
16 Debug,
17 Planning,
18 Advanced,
19}
20
21impl SlashCategory {
22 #[must_use]
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::Session => "Session",
26 Self::Model => "Model",
27 Self::Info => "Info",
28 Self::Memory => "Memory",
29 Self::Tools => "Tools",
30 Self::Debug => "Debug",
31 Self::Planning => "Planning",
32 Self::Advanced => "Advanced",
33 }
34 }
35}
36
37pub struct SlashCommandInfo {
39 pub name: &'static str,
40 pub args: &'static str,
42 pub description: &'static str,
43 pub category: SlashCategory,
44 pub feature_gate: Option<&'static str>,
47}
48
49pub const COMMANDS: &[SlashCommandInfo] = &[
54 SlashCommandInfo {
56 name: "/help",
57 args: "",
58 description: "Show this help message",
59 category: SlashCategory::Info,
60 feature_gate: None,
61 },
62 SlashCommandInfo {
63 name: "/status",
64 args: "",
65 description: "Show current session status (provider, model, tokens, uptime)",
66 category: SlashCategory::Info,
67 feature_gate: None,
68 },
69 SlashCommandInfo {
70 name: "/skills",
71 args: "",
72 description: "List loaded skills (grouped by category when available)",
73 category: SlashCategory::Info,
74 feature_gate: None,
75 },
76 SlashCommandInfo {
77 name: "/skills confusability",
78 args: "",
79 description: "Show skill pairs with high embedding similarity (potential disambiguation failures)",
80 category: SlashCategory::Info,
81 feature_gate: None,
82 },
83 SlashCommandInfo {
84 name: "/guardrail",
85 args: "",
86 description: "Show guardrail status (provider, model, action, timeout, stats)",
87 category: SlashCategory::Info,
88 feature_gate: Some("guardrail"),
89 },
90 SlashCommandInfo {
91 name: "/log",
92 args: "",
93 description: "Toggle verbose log output",
94 category: SlashCategory::Info,
95 feature_gate: None,
96 },
97 SlashCommandInfo {
99 name: "/exit",
100 args: "",
101 description: "Exit the agent (also: /quit)",
102 category: SlashCategory::Session,
103 feature_gate: None,
104 },
105 SlashCommandInfo {
106 name: "/new",
107 args: "[--no-digest] [--keep-plan]",
108 description: "Start a new conversation (reset context, preserve memory and MCP)",
109 category: SlashCategory::Session,
110 feature_gate: None,
111 },
112 SlashCommandInfo {
113 name: "/clear",
114 args: "",
115 description: "Clear conversation history",
116 category: SlashCategory::Session,
117 feature_gate: None,
118 },
119 SlashCommandInfo {
120 name: "/reset",
121 args: "",
122 description: "Reset conversation history (alias for /clear, replies with confirmation)",
123 category: SlashCategory::Session,
124 feature_gate: None,
125 },
126 SlashCommandInfo {
127 name: "/clear-queue",
128 args: "",
129 description: "Discard queued messages",
130 category: SlashCategory::Session,
131 feature_gate: None,
132 },
133 SlashCommandInfo {
134 name: "/compact",
135 args: "",
136 description: "Compact the context window",
137 category: SlashCategory::Session,
138 feature_gate: None,
139 },
140 SlashCommandInfo {
142 name: "/model",
143 args: "[id|refresh]",
144 description: "Show or switch the active model",
145 category: SlashCategory::Model,
146 feature_gate: None,
147 },
148 SlashCommandInfo {
149 name: "/provider",
150 args: "[name|status]",
151 description: "List configured providers or switch to one by name",
152 category: SlashCategory::Model,
153 feature_gate: None,
154 },
155 SlashCommandInfo {
157 name: "/feedback",
158 args: "<skill> <message>",
159 description: "Submit feedback for a skill",
160 category: SlashCategory::Memory,
161 feature_gate: None,
162 },
163 SlashCommandInfo {
164 name: "/graph",
165 args: "[subcommand]",
166 description: "Query or manage the knowledge graph",
167 category: SlashCategory::Memory,
168 feature_gate: None,
169 },
170 SlashCommandInfo {
171 name: "/memory",
172 args: "[tiers|promote <id>...]",
173 description: "Show memory tier stats or manually promote messages to semantic tier",
174 category: SlashCategory::Memory,
175 feature_gate: None,
176 },
177 SlashCommandInfo {
178 name: "/guidelines",
179 args: "",
180 description: "Show current compression guidelines",
181 category: SlashCategory::Memory,
182 feature_gate: Some("compression-guidelines"),
183 },
184 SlashCommandInfo {
186 name: "/skill",
187 args: "<name>",
188 description: "Load and display a skill body",
189 category: SlashCategory::Tools,
190 feature_gate: None,
191 },
192 SlashCommandInfo {
193 name: "/skill create",
194 args: "<description>",
195 description: "Generate a SKILL.md from natural language via LLM",
196 category: SlashCategory::Tools,
197 feature_gate: None,
198 },
199 SlashCommandInfo {
200 name: "/mcp",
201 args: "[add|list|tools|remove]",
202 description: "Manage MCP servers",
203 category: SlashCategory::Tools,
204 feature_gate: None,
205 },
206 SlashCommandInfo {
207 name: "/image",
208 args: "<path>",
209 description: "Attach an image to the next message",
210 category: SlashCategory::Tools,
211 feature_gate: None,
212 },
213 SlashCommandInfo {
214 name: "/agent",
215 args: "[subcommand]",
216 description: "Manage sub-agents",
217 category: SlashCategory::Tools,
218 feature_gate: None,
219 },
220 SlashCommandInfo {
222 name: "/plan",
223 args: "[goal|confirm|cancel|status|list|resume|retry]",
224 description: "Create or manage execution plans",
225 category: SlashCategory::Planning,
226 feature_gate: None,
227 },
228 SlashCommandInfo {
230 name: "/debug-dump",
231 args: "[path]",
232 description: "Enable or toggle debug dump output",
233 category: SlashCategory::Debug,
234 feature_gate: None,
235 },
236 SlashCommandInfo {
237 name: "/dump-format",
238 args: "<json|raw|trace>",
239 description: "Switch debug dump format at runtime",
240 category: SlashCategory::Debug,
241 feature_gate: None,
242 },
243 #[cfg(feature = "scheduler")]
245 SlashCommandInfo {
246 name: "/scheduler",
247 args: "[list]",
248 description: "List scheduled tasks",
249 category: SlashCategory::Tools,
250 feature_gate: Some("scheduler"),
251 },
252 SlashCommandInfo {
253 name: "/experiment",
254 args: "[subcommand]",
255 description: "Experimental features",
256 category: SlashCategory::Advanced,
257 feature_gate: Some("experiments"),
258 },
259 SlashCommandInfo {
260 name: "/lsp",
261 args: "",
262 description: "Show LSP context status",
263 category: SlashCategory::Advanced,
264 feature_gate: Some("lsp-context"),
265 },
266 SlashCommandInfo {
267 name: "/policy",
268 args: "[status|check <tool> [args_json]]",
269 description: "Inspect policy status or dry-run evaluation",
270 category: SlashCategory::Tools,
271 feature_gate: Some("policy-enforcer"),
272 },
273 SlashCommandInfo {
274 name: "/focus",
275 args: "",
276 description: "Show Focus Agent status (active session, knowledge block size)",
277 category: SlashCategory::Advanced,
278 feature_gate: Some("context-compression"),
279 },
280 SlashCommandInfo {
281 name: "/sidequest",
282 args: "",
283 description: "Show SideQuest eviction stats (passes run, tokens freed)",
284 category: SlashCategory::Advanced,
285 feature_gate: Some("context-compression"),
286 },
287];
288
289use zeph_llm::provider::LlmProvider;
290
291use super::Agent;
292use super::error;
293use super::message_queue::{MAX_IMAGE_BYTES, detect_image_mime};
294
295impl<C: crate::channel::Channel> Agent<C> {
296 #[allow(clippy::too_many_lines)]
302 pub(super) async fn handle_builtin_command(
303 &mut self,
304 trimmed: &str,
305 ) -> Result<Option<bool>, error::AgentError> {
306 if trimmed == "/clear-queue" {
307 let n = self.clear_queue();
308 self.notify_queue_count().await;
309 self.channel
310 .send(&format!("Cleared {n} queued messages."))
311 .await?;
312 let _ = self.channel.flush_chunks().await;
313 return Ok(Some(false));
314 }
315
316 if trimmed == "/compact" {
317 if self.msg.messages.len() > self.context_manager.compaction_preserve_tail + 1 {
318 match self.compact_context().await {
319 Ok(
320 super::context::CompactionOutcome::Compacted
321 | super::context::CompactionOutcome::NoChange,
322 ) => {
323 let _ = self.channel.send("Context compacted successfully.").await;
324 }
325 Ok(super::context::CompactionOutcome::ProbeRejected) => {
326 let _ = self
327 .channel
328 .send(
329 "Compaction rejected: summary quality below threshold. \
330 Original context preserved.",
331 )
332 .await;
333 }
334 Err(e) => {
335 let _ = self.channel.send(&format!("Compaction failed: {e}")).await;
336 }
337 }
338 } else {
339 let _ = self.channel.send("Nothing to compact.").await;
340 }
341 let _ = self.channel.flush_chunks().await;
342 return Ok(Some(false));
343 }
344
345 if trimmed == "/new" || trimmed.starts_with("/new ") {
346 let args = trimmed.strip_prefix("/new").unwrap_or("").trim();
347 let keep_plan = args.split_whitespace().any(|a| a == "--keep-plan");
348 let no_digest = args.split_whitespace().any(|a| a == "--no-digest");
349 match self.reset_conversation(keep_plan, no_digest).await {
350 Ok((old_id, new_id)) => {
351 let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
352 let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
353 let keep_note = if keep_plan { " (plan preserved)" } else { "" };
354 self.channel
355 .send(&format!(
356 "New conversation started. Previous: {old} → Current: {new}{keep_note}"
357 ))
358 .await?;
359 }
360 Err(e) => {
361 self.channel
362 .send(&format!("Failed to start new conversation: {e}"))
363 .await?;
364 }
365 }
366 let _ = self.channel.flush_chunks().await;
367 return Ok(Some(false));
368 }
369
370 if trimmed == "/clear" {
371 self.clear_history();
372 self.tool_orchestrator.clear_cache();
373 if let Ok(mut urls) = self.security.user_provided_urls.write() {
374 urls.clear();
375 }
376 let _ = self.channel.flush_chunks().await;
377 return Ok(Some(false));
378 }
379
380 if trimmed == "/reset" {
381 self.clear_history();
382 self.tool_orchestrator.clear_cache();
383 if let Ok(mut urls) = self.security.user_provided_urls.write() {
384 urls.clear();
385 }
386 self.channel.send("Conversation history reset.").await?;
387 let _ = self.channel.flush_chunks().await;
388 return Ok(Some(false));
389 }
390
391 if trimmed == "/cache-stats" {
392 let stats = self.tool_orchestrator.cache_stats();
393 self.channel.send(&stats).await?;
394 let _ = self.channel.flush_chunks().await;
395 return Ok(Some(false));
396 }
397
398 if trimmed == "/model" || trimmed.starts_with("/model ") {
399 self.handle_model_command(trimmed).await;
400 let _ = self.channel.flush_chunks().await;
401 return Ok(Some(false));
402 }
403
404 if trimmed == "/provider" || trimmed.starts_with("/provider ") {
405 self.handle_provider_command(trimmed).await;
406 let _ = self.channel.flush_chunks().await;
407 return Ok(Some(false));
408 }
409
410 if trimmed == "/debug-dump" || trimmed.starts_with("/debug-dump ") {
411 self.handle_debug_dump_command(trimmed).await;
412 let _ = self.channel.flush_chunks().await;
413 return Ok(Some(false));
414 }
415
416 if trimmed.starts_with("/dump-format") {
417 self.handle_dump_format_command(trimmed).await;
418 let _ = self.channel.flush_chunks().await;
419 return Ok(Some(false));
420 }
421
422 if trimmed == "/exit" || trimmed == "/quit" {
423 if self.channel.supports_exit() {
424 return Ok(Some(true));
425 }
426 let _ = self
427 .channel
428 .send("/exit is not supported in this channel.")
429 .await;
430 return Ok(Some(false));
431 }
432
433 Ok(None)
434 }
435
436 #[allow(clippy::too_many_lines)]
439 pub(super) async fn dispatch_slash_command(
440 &mut self,
441 trimmed: &str,
442 ) -> Option<Result<(), error::AgentError>> {
443 macro_rules! handled {
444 ($expr:expr) => {{
445 if let Err(e) = $expr {
446 return Some(Err(e));
447 }
448 let _ = self.channel.flush_chunks().await;
449 return Some(Ok(()));
450 }};
451 }
452
453 let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
454 if !slash_urls.is_empty()
455 && let Ok(mut set) = self.security.user_provided_urls.write()
456 {
457 set.extend(slash_urls);
458 }
459
460 if trimmed == "/help" {
461 handled!(self.handle_help_command().await);
462 }
463
464 if trimmed == "/status" {
465 handled!(self.handle_status_command().await);
466 }
467 if trimmed == "/guardrail" {
468 handled!(self.handle_guardrail_command().await);
469 }
470
471 if trimmed == "/skills" || trimmed.starts_with("/skills ") {
472 let subcommand = trimmed.strip_prefix("/skills").unwrap_or("").trim();
473 handled!(self.handle_skills_family(subcommand).await);
474 }
475
476 if trimmed == "/skill" || trimmed.starts_with("/skill ") {
477 let rest = trimmed
478 .strip_prefix("/skill")
479 .unwrap_or("")
480 .trim()
481 .to_owned();
482 handled!(self.handle_skill_command(&rest).await);
483 }
484
485 if trimmed == "/feedback" || trimmed.starts_with("/feedback ") {
486 let rest = trimmed
487 .strip_prefix("/feedback")
488 .unwrap_or("")
489 .trim()
490 .to_owned();
491 handled!(self.handle_feedback(&rest).await);
492 }
493
494 if trimmed == "/mcp" || trimmed.starts_with("/mcp ") {
495 let args = trimmed.strip_prefix("/mcp").unwrap_or("").trim().to_owned();
496 handled!(self.handle_mcp_command(&args).await);
497 }
498
499 if trimmed == "/image" || trimmed.starts_with("/image ") {
500 let path = trimmed
501 .strip_prefix("/image")
502 .unwrap_or("")
503 .trim()
504 .to_owned();
505 if path.is_empty() {
506 handled!(
507 self.channel
508 .send("Usage: /image <path>")
509 .await
510 .map_err(Into::into)
511 );
512 }
513 handled!(self.handle_image_command(&path).await);
514 }
515
516 if trimmed == "/plan" || trimmed.starts_with("/plan ") {
517 return Some(self.dispatch_plan_command(trimmed).await);
518 }
519
520 if trimmed == "/graph" || trimmed.starts_with("/graph ") {
521 handled!(self.handle_graph_command(trimmed).await);
522 }
523
524 if trimmed == "/memory" || trimmed.starts_with("/memory ") {
525 handled!(self.handle_memory_command(trimmed).await);
526 }
527 if trimmed == "/guidelines" {
528 handled!(self.handle_guidelines_command().await);
529 }
530
531 #[cfg(feature = "scheduler")]
532 if trimmed == "/scheduler" || trimmed.starts_with("/scheduler ") {
533 handled!(self.handle_scheduler_command(trimmed).await);
534 }
535 if trimmed == "/experiment" || trimmed.starts_with("/experiment ") {
536 handled!(self.handle_experiment_command(trimmed).await);
537 }
538 if trimmed == "/lsp" {
539 handled!(self.handle_lsp_status_command().await);
540 }
541 if trimmed == "/policy" || trimmed.starts_with("/policy ") {
542 let args = trimmed
543 .strip_prefix("/policy")
544 .unwrap_or("")
545 .trim()
546 .to_owned();
547 handled!(self.handle_policy_command(&args).await);
548 }
549
550 if trimmed == "/log" {
551 handled!(self.handle_log_command().await);
552 }
553
554 if trimmed.starts_with("/agent") || trimmed.starts_with('@') {
555 return self.dispatch_agent_command(trimmed).await;
556 }
557 if trimmed == "/focus" {
558 handled!(self.handle_focus_status_command().await);
559 }
560 if trimmed == "/sidequest" {
561 handled!(self.handle_sidequest_status_command().await);
562 }
563
564 None
565 }
566
567 pub(super) async fn dispatch_agent_command(
568 &mut self,
569 trimmed: &str,
570 ) -> Option<Result<(), error::AgentError>> {
571 let known: Vec<String> = self
572 .orchestration
573 .subagent_manager
574 .as_ref()
575 .map(|m| m.definitions().iter().map(|d| d.name.clone()).collect())
576 .unwrap_or_default();
577 match crate::subagent::AgentCommand::parse(trimmed, &known) {
578 Ok(cmd) => {
579 if let Some(msg) = self.handle_agent_command(cmd).await
580 && let Err(e) = self.channel.send(&msg).await
581 {
582 return Some(Err(e.into()));
583 }
584 let _ = self.channel.flush_chunks().await;
585 Some(Ok(()))
586 }
587 Err(e) if trimmed.starts_with('@') => {
588 tracing::debug!("@mention not matched as agent: {e}");
589 None
590 }
591 Err(e) => {
592 if let Err(send_err) = self.channel.send(&e.to_string()).await {
593 return Some(Err(send_err.into()));
594 }
595 let _ = self.channel.flush_chunks().await;
596 Some(Ok(()))
597 }
598 }
599 }
600
601 pub(super) async fn handle_image_command(
602 &mut self,
603 path: &str,
604 ) -> Result<(), error::AgentError> {
605 use std::path::Component;
606 use zeph_llm::provider::{ImageData, MessagePart};
607
608 let has_parent_dir = std::path::Path::new(path)
609 .components()
610 .any(|c| c == Component::ParentDir);
611 if has_parent_dir {
612 self.channel
613 .send("Invalid image path: path traversal not allowed")
614 .await?;
615 let _ = self.channel.flush_chunks().await;
616 return Ok(());
617 }
618
619 let data = match std::fs::read(path) {
620 Ok(d) => d,
621 Err(e) => {
622 self.channel
623 .send(&format!("Cannot read image {path}: {e}"))
624 .await?;
625 let _ = self.channel.flush_chunks().await;
626 return Ok(());
627 }
628 };
629 if data.len() > MAX_IMAGE_BYTES {
630 self.channel
631 .send(&format!(
632 "Image {path} exceeds size limit ({} MB), skipping",
633 MAX_IMAGE_BYTES / 1024 / 1024
634 ))
635 .await?;
636 let _ = self.channel.flush_chunks().await;
637 return Ok(());
638 }
639 let mime_type = detect_image_mime(Some(path)).to_string();
640 self.msg
641 .pending_image_parts
642 .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
643 self.channel
644 .send(&format!("Image loaded: {path}. Send your message."))
645 .await?;
646 let _ = self.channel.flush_chunks().await;
647 Ok(())
648 }
649
650 pub(super) async fn handle_help_command(&mut self) -> Result<(), error::AgentError> {
651 use std::fmt::Write;
652
653 let mut out = String::from("Slash commands:\n\n");
654
655 let categories = [
656 SlashCategory::Info,
657 SlashCategory::Session,
658 SlashCategory::Model,
659 SlashCategory::Memory,
660 SlashCategory::Tools,
661 SlashCategory::Planning,
662 SlashCategory::Debug,
663 SlashCategory::Advanced,
664 ];
665
666 for cat in &categories {
667 let entries: Vec<_> = COMMANDS.iter().filter(|c| &c.category == cat).collect();
668 if entries.is_empty() {
669 continue;
670 }
671 let _ = writeln!(out, "{}:", cat.as_str());
672 for cmd in entries {
673 if cmd.args.is_empty() {
674 let _ = write!(out, " {}", cmd.name);
675 } else {
676 let _ = write!(out, " {} {}", cmd.name, cmd.args);
677 }
678 let _ = write!(out, " — {}", cmd.description);
679 if let Some(feat) = cmd.feature_gate {
680 let _ = write!(out, " [requires: {feat}]");
681 }
682 let _ = writeln!(out);
683 }
684 let _ = writeln!(out);
685 }
686
687 self.channel.send(out.trim_end()).await?;
688 Ok(())
689 }
690
691 #[allow(clippy::too_many_lines)]
692 pub(super) async fn handle_status_command(&mut self) -> Result<(), error::AgentError> {
693 use std::fmt::Write;
694 use zeph_llm::provider::Role;
695
696 let uptime = self.lifecycle.start_time.elapsed().as_secs();
697 let msg_count = self
698 .msg
699 .messages
700 .iter()
701 .filter(|m| m.role == Role::User)
702 .count();
703
704 let (
705 api_calls,
706 prompt_tokens,
707 completion_tokens,
708 cost_cents,
709 mcp_servers,
710 orch_plans,
711 orch_tasks,
712 orch_completed,
713 orch_failed,
714 orch_skipped,
715 ) = if let Some(ref tx) = self.metrics.metrics_tx {
716 let m = tx.borrow();
717 (
718 m.api_calls,
719 m.prompt_tokens,
720 m.completion_tokens,
721 m.cost_spent_cents,
722 m.mcp_server_count,
723 m.orchestration.plans_total,
724 m.orchestration.tasks_total,
725 m.orchestration.tasks_completed,
726 m.orchestration.tasks_failed,
727 m.orchestration.tasks_skipped,
728 )
729 } else {
730 (0, 0, 0, 0.0, 0, 0, 0, 0, 0, 0)
731 };
732
733 let skill_count = self
734 .skill_state
735 .registry
736 .read()
737 .map(|r| r.all_meta().len())
738 .unwrap_or(0);
739
740 let mut out = String::from("Session status:\n\n");
741 let _ = writeln!(out, "Provider: {}", self.provider.name());
742 let _ = writeln!(out, "Model: {}", self.runtime.model_name);
743 let _ = writeln!(out, "Uptime: {uptime}s");
744 let _ = writeln!(out, "Turns: {msg_count}");
745 let _ = writeln!(out, "API calls: {api_calls}");
746 let _ = writeln!(
747 out,
748 "Tokens: {prompt_tokens} prompt / {completion_tokens} completion"
749 );
750 let _ = writeln!(out, "Skills: {skill_count}");
751 let _ = writeln!(out, "MCP: {mcp_servers} server(s)");
752 if let Some(ref tf) = self.tool_schema_filter {
753 let _ = writeln!(
754 out,
755 "Filter: enabled (top_k={}, always_on={}, {} embeddings)",
756 tf.top_k(),
757 tf.always_on_count(),
758 tf.embedding_count(),
759 );
760 }
761 if let Some(ref adv) = self.runtime.adversarial_policy_info {
762 let provider_display = if adv.provider.is_empty() {
763 "default"
764 } else {
765 adv.provider.as_str()
766 };
767 let _ = writeln!(
768 out,
769 "Adv gate: enabled (provider={}, policies={}, fail_open={})",
770 provider_display, adv.policy_count, adv.fail_open
771 );
772 }
773 if cost_cents > 0.0 {
774 let _ = writeln!(out, "Cost: ${:.4}", cost_cents / 100.0);
775 }
776 if orch_plans > 0 {
777 let _ = writeln!(out);
778 let _ = writeln!(out, "Orchestration:");
779 let _ = writeln!(out, " Plans: {orch_plans}");
780 let _ = writeln!(out, " Tasks: {orch_completed}/{orch_tasks} completed");
781 if orch_failed > 0 {
782 let _ = writeln!(out, " Failed: {orch_failed}");
783 }
784 if orch_skipped > 0 {
785 let _ = writeln!(out, " Skipped: {orch_skipped}");
786 }
787 }
788
789 {
790 use crate::config::PruningStrategy;
791 if matches!(
792 self.context_manager.compression.pruning_strategy,
793 PruningStrategy::Subgoal | PruningStrategy::SubgoalMig
794 ) {
795 let _ = writeln!(out);
796 let _ = writeln!(
797 out,
798 "Pruning: {}",
799 match self.context_manager.compression.pruning_strategy {
800 PruningStrategy::SubgoalMig => "subgoal_mig",
801 _ => "subgoal",
802 }
803 );
804 let subgoal_count = self.compression.subgoal_registry.subgoals.len();
805 let _ = writeln!(out, "Subgoals: {subgoal_count} tracked");
806 if let Some(active) = self.compression.subgoal_registry.active_subgoal() {
807 let _ = writeln!(out, "Active: \"{}\"", active.description);
808 } else {
809 let _ = writeln!(out, "Active: (none yet)");
810 }
811 }
812 }
813
814 let gc = &self.memory_state.graph_config;
815 if gc.enabled {
816 let _ = writeln!(out);
817 if gc.spreading_activation.enabled {
818 let _ = writeln!(
819 out,
820 "Graph recall: spreading activation (lambda={:.2}, hops={})",
821 gc.spreading_activation.decay_lambda, gc.spreading_activation.max_hops,
822 );
823 } else {
824 let _ = writeln!(out, "Graph recall: BFS (hops={})", gc.max_hops,);
825 }
826 }
827
828 self.channel.send(out.trim_end()).await?;
829 Ok(())
830 }
831
832 pub(super) async fn handle_guardrail_command(&mut self) -> Result<(), error::AgentError> {
833 use std::fmt::Write;
834
835 let mut out = String::new();
836 if let Some(ref guardrail) = self.security.guardrail {
837 let stats = guardrail.stats();
838 let _ = writeln!(out, "Guardrail: enabled");
839 let _ = writeln!(out, "Action: {:?}", guardrail.action());
840 let _ = writeln!(out, "Fail strategy: {:?}", guardrail.fail_strategy());
841 let _ = writeln!(out, "Timeout: {}ms", guardrail.timeout_ms());
842 let _ = writeln!(
843 out,
844 "Tool scan: {}",
845 if guardrail.scan_tool_output() {
846 "enabled"
847 } else {
848 "disabled"
849 }
850 );
851 let _ = writeln!(out, "\nStats:");
852 let _ = writeln!(out, " Total checks: {}", stats.total_checks);
853 let _ = writeln!(out, " Flagged: {}", stats.flagged_count);
854 let _ = writeln!(out, " Errors: {}", stats.error_count);
855 let _ = writeln!(out, " Avg latency: {}ms", stats.avg_latency_ms());
856 } else {
857 out.push_str("Guardrail: disabled\n");
858 out.push_str(
859 "Enable with: --guardrail flag or [security.guardrail] enabled = true in config",
860 );
861 }
862
863 self.channel.send(out.trim_end()).await?;
864 Ok(())
865 }
866
867 pub(super) async fn handle_skills_family(
868 &mut self,
869 subcommand: &str,
870 ) -> Result<(), error::AgentError> {
871 match subcommand {
872 "" => self.handle_skills_command().await,
873 "confusability" => self.handle_skills_confusability_command().await,
874 other => {
875 self.channel
876 .send(&format!(
877 "Unknown /skills subcommand: '{other}'. Available: confusability"
878 ))
879 .await?;
880 Ok(())
881 }
882 }
883 }
884
885 pub(super) async fn handle_skills_command(&mut self) -> Result<(), error::AgentError> {
886 use std::collections::BTreeMap;
887 use std::fmt::Write;
888
889 let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
890 .skill_state
891 .registry
892 .read()
893 .expect("registry read lock")
894 .all_meta()
895 .into_iter()
896 .cloned()
897 .collect();
898
899 let mut trust_map: std::collections::HashMap<String, String> =
900 std::collections::HashMap::new();
901 for meta in &all_meta {
902 if let Some(memory) = &self.memory_state.memory {
903 let info = memory
904 .sqlite()
905 .load_skill_trust(&meta.name)
906 .await
907 .ok()
908 .flatten()
909 .map_or_else(String::new, |r| format!(" [{}]", r.trust_level));
910 trust_map.insert(meta.name.clone(), info);
911 }
912 }
913
914 let mut output = String::from("Available skills:\n\n");
915
916 let has_categories = all_meta.iter().any(|m| m.category.is_some());
917 if has_categories {
918 let mut by_category: BTreeMap<&str, Vec<&zeph_skills::loader::SkillMeta>> =
919 BTreeMap::new();
920 for meta in &all_meta {
921 let cat = meta.category.as_deref().unwrap_or("other");
922 by_category.entry(cat).or_default().push(meta);
923 }
924 for (cat, skills) in &by_category {
925 let _ = writeln!(output, "[{cat}]");
926 for meta in skills {
927 let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
928 let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
929 }
930 output.push('\n');
931 }
932 } else {
933 for meta in &all_meta {
934 let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
935 let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
936 }
937 }
938
939 if let Some(memory) = &self.memory_state.memory {
940 match memory.sqlite().load_skill_usage().await {
941 Ok(usage) if !usage.is_empty() => {
942 output.push_str("\nUsage statistics:\n\n");
943 for row in &usage {
944 let _ = writeln!(
945 output,
946 "- {}: {} invocations (last: {})",
947 row.skill_name, row.invocation_count, row.last_used_at,
948 );
949 }
950 }
951 Ok(_) => {}
952 Err(e) => tracing::warn!("failed to load skill usage: {e:#}"),
953 }
954 }
955
956 self.channel.send(&output).await?;
957 Ok(())
958 }
959
960 pub(super) async fn handle_skills_confusability_command(
961 &mut self,
962 ) -> Result<(), error::AgentError> {
963 let threshold = self.skill_state.confusability_threshold;
964 if threshold <= 0.0 {
965 self.channel
966 .send(
967 "Confusability monitoring is disabled. \
968 Set [skills] confusability_threshold in config (e.g. 0.85) to enable.",
969 )
970 .await?;
971 return Ok(());
972 }
973
974 let Some(matcher) = &self.skill_state.matcher else {
975 self.channel
976 .send("Skill matcher not available (no embedding provider configured).")
977 .await?;
978 return Ok(());
979 };
980
981 let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
982 .skill_state
983 .registry
984 .read()
985 .expect("registry read lock")
986 .all_meta()
987 .into_iter()
988 .cloned()
989 .collect();
990 let refs: Vec<&zeph_skills::loader::SkillMeta> = all_meta.iter().collect();
991
992 let report = matcher.confusability_report(&refs, threshold).await;
993 self.channel.send(&report.to_string()).await?;
994 Ok(())
995 }
996}