hyperhive/hive-agent/src/stream_enrich.rs
iris b25c44f7cb feat(#2196): move send/ask/answer body to backend _body field
The three message-bearing hyperhive tools (send, ask, answer) previously
had named JS branches in renderRichToolUse that each:
- computed a summary string (recipient / line count)
- rendered the body text via detailsOpenMd (marked + DOMPurify)

This commit moves the body text and summary string to the backend,
reducing the JS dispatch table to a single generic markdown path.

Backend (stream_enrich.rs):
- rich_tool_body: new 'markdown' body_type for send/ask/answer —
  stamps _body with input.body / input.question / input.answer
- fmt_hyperhive_message_tool: new helper formats _summary as
  'send* → to' / 'ask* → to' / 'answer* #id' with ' · NL' when
  the body spans multiple lines; extracted out of fmt_hyperhive_tool
  to keep it under the too_many_lines limit
- doc: updated rich_tool_body docstring to list the new 'markdown' type

Frontend (app.js):
- Remove the three named branches (send/ask/answer) from renderRichToolUse
- Extend the generic _body path: 'markdown' type calls detailsOpenMd
- The ask-form slot logic (operator inline-answer binding) is preserved
  within the markdown branch, now reading the question from c._body
  instead of input.question — DOM mounting remains client-side
2026-07-20 19:13:43 +02:00

922 lines
33 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Enrich raw claude stream-json values before SSE delivery.
//!
//! A single [`enrich`] function stamps `_icon`, `_summary`, `_category`,
//! and optionally `_body` onto [`crate::events::LiveEvent::Stream`] payloads
//! so the frontend can read pre-computed fields instead of duplicating the
//! dispatch logic in JavaScript.
//!
//! The sqlite event log stores raw (un-enriched) events — the DB never needs
//! migration when the enrichment logic changes. Enrichment is applied at
//! SSE-emit time in [`crate::web_ui::stream`] so both the live tail
//! (`events/stream`) and the history replay (`events/history`) endpoints
//! deliver the same enriched shape.
//!
//! # Migration (two-phase)
//!
//! **Phase 1** (this change): backend stamps `_icon`/`_summary`/`_category`
//! fields; the client reads them when present and falls back to its own JS
//! tables when absent. Zero user-visible change — a no-op for clients that
//! haven't yet been updated.
//!
//! **Phase 2** (follow-up): the client drops the JS tables once phase 1 is
//! deployed everywhere.
use serde_json::{Value, json};
/// Stamp enrichment fields onto a raw claude stream-json [`Value`].
///
/// - `type="system"` events get `_category` + `_summary` (and `_body` for
/// expandable detail, e.g. `commands_changed`).
/// - `type="assistant"` events get `_icon`, `_summary`, and optionally
/// `_category: "rich"` stamped onto each `message.content[]` entry that
/// has `type="tool_use"`.
///
/// No-ops for unknown/unhandled top-level types. Existing `_`-prefixed fields
/// are left unchanged so the call is idempotent (history replay may hit
/// already-enriched values if the DB is ever pre-populated by a future phase).
pub fn enrich(v: &mut Value) {
match v.get("type").and_then(Value::as_str).unwrap_or("") {
// Top-level result/rate_limit_event are drop-category noise — stamp
// the same category the frontend uses to silently discard them.
"result" | "rate_limit_event" => {
if let Some(obj) = v.as_object_mut() {
obj.entry("_category").or_insert_with(|| json!("drop"));
}
}
"system" => enrich_system(v),
"assistant" => enrich_assistant(v),
_ => {}
}
}
// ---------------------------------------------------------------------------
// system events
// ---------------------------------------------------------------------------
fn enrich_system(v: &mut Value) {
if v.get("_category").is_some() {
return; // idempotent
}
let subtype = v
.get("subtype")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let (category, summary, body) = system_fields(v, &subtype);
let Some(obj) = v.as_object_mut() else { return };
obj.insert("_category".to_owned(), json!(category));
if let Some(s) = summary {
obj.insert("_summary".to_owned(), json!(s));
}
if let Some(b) = body {
obj.insert("_body".to_owned(), json!(b));
}
}
/// Compute `(category, summary, body)` for a `type="system"` event.
///
/// Categories understood by the frontend:
/// - `"drop"` — client discards without rendering (noise)
/// - `"thinking_tok"` — client updates a single in-place counter row
/// - `"note"` — client renders `_summary` as a terminal note line
/// - `"details"` — client renders a collapsible `<details>` with `_summary`
/// as the header and `_body` as the expanded text
fn system_fields(v: &Value, subtype: &str) -> (&'static str, Option<String>, Option<String>) {
match subtype {
// Silent startup / stream-end noise — drop.
"init" | "result" | "rate_limit_event" => ("drop", None, None),
// Live thinking-token counter.
"thinking_tokens" => {
let n = v.get("estimated_tokens").and_then(Value::as_u64);
let s = n.map_or_else(
|| "thinking…".to_owned(),
|n| format!("thinking… ~{n} tokens"),
);
("thinking_tok", Some(s), None)
}
// API retry back-off.
"api_retry" => {
let mut parts = vec!["⚠ api retry".to_owned()];
if let (Some(a), Some(m)) = (
v.get("attempt").and_then(Value::as_u64),
v.get("max_retries").and_then(Value::as_u64),
) {
parts.push(format!("{a}/{m}"));
}
if let Some(e) = v.get("error").and_then(Value::as_str) {
parts.push(e.to_owned());
} else if let Some(s) = v.get("error_status").and_then(Value::as_u64) {
parts.push(format!("HTTP {s}"));
}
if let Some(ms) = v.get("retry_delay_ms").and_then(Value::as_f64) {
parts.push(format!("{:.0}ms", ms.round()));
}
("note", Some(parts.join(" · ")), None)
}
// Hard API error.
"api_error" => {
let msg = v
.get("error")
.or_else(|| v.get("message"))
.and_then(Value::as_str)
.map_or_else(
|| {
v.get("error_status")
.and_then(Value::as_u64)
.map_or("unknown".to_owned(), |s| format!("HTTP {s}"))
},
str::to_owned,
);
("note", Some(format!("✗ api error · {msg}")), None)
}
// Plugin (MCP server / slash-command provider) load progress.
"plugin_install" => {
let status = v.get("status").and_then(Value::as_str).unwrap_or("?");
let label = match status {
"completed" => "✓ done",
"started" => "loading…",
other => other,
};
("note", Some(format!("⚙ plugin install · {label}")), None)
}
// Available slash-command set changed — expandable list.
"commands_changed" => {
let cmds = v.get("commands").and_then(Value::as_array);
let count = cmds.map_or(0, Vec::len);
let summary = format!("⚙ commands changed · {count} available");
let body = cmds.map(|cmds| {
cmds.iter()
.filter_map(|c| c.get("name").and_then(Value::as_str))
.map(|n| format!("/{n}"))
.collect::<Vec<_>>()
.join("\n")
});
("details", Some(summary), body)
}
// Compaction complete — pre/post token counts + duration.
"compact_boundary" => {
let m = v
.get("compact_metadata")
.cloned()
.unwrap_or_else(|| json!({}));
let mut parts = vec!["⚙ compact".to_owned()];
if let Some(t) = m.get("trigger").and_then(Value::as_str) {
parts.push(t.to_owned());
}
if let (Some(pre), Some(post)) = (
m.get("pre_tokens").and_then(Value::as_u64),
m.get("post_tokens").and_then(Value::as_u64),
) {
parts.push(format!("{}{} tokens", fmt_tok(pre), fmt_tok(post)));
}
if let Some(ms) = m.get("duration_ms").and_then(Value::as_u64) {
let dur = if ms < 1000 {
format!("{ms}ms")
} else {
#[allow(clippy::cast_precision_loss)]
let s_f = ms as f64 / 1_000.0;
format!("{s_f:.1}s")
};
parts.push(dur);
}
("note", Some(parts.join(" · ")), None)
}
// Generic "still working" heartbeat tick.
"status" => ("note", Some("⚙ status".to_owned()), None),
// Unknown subtype — render with the subtype label as a muted note.
other => ("note", Some(format!("{other}")), None),
}
}
// ---------------------------------------------------------------------------
// assistant events
// ---------------------------------------------------------------------------
fn enrich_assistant(v: &mut Value) {
// Navigate message.content[] — absent on text-only turns.
let Some(content) = v
.get_mut("message")
.and_then(|m| m.get_mut("content"))
.and_then(Value::as_array_mut)
else {
return;
};
for entry in content.iter_mut() {
enrich_tool_use_entry(entry);
}
}
fn enrich_tool_use_entry(entry: &mut Value) {
if entry.get("type").and_then(Value::as_str) != Some("tool_use") {
return;
}
if entry.get("_icon").is_some() {
return; // idempotent
}
let name = entry
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let input = entry.get("input").cloned().unwrap_or_else(|| json!({}));
let icon = tool_icon(&name);
let summary = fmt_tool_use(&name, &input);
let rich = is_rich_tool(&name);
let body = rich_tool_body(&name, &input);
let Some(obj) = entry.as_object_mut() else {
return;
};
obj.insert("_icon".to_owned(), json!(icon));
obj.insert("_summary".to_owned(), json!(summary));
if rich {
obj.insert("_category".to_owned(), json!("rich"));
}
if let Some((b, bt)) = body {
obj.insert("_body".to_owned(), json!(b));
obj.insert("_body_type".to_owned(), json!(bt));
}
}
// ---------------------------------------------------------------------------
// tool helpers
// ---------------------------------------------------------------------------
/// Whether this tool is rendered by the frontend's *rich* renderer (diff
/// view, body expansion) rather than the flat `_summary` row. Flagged with
/// `_category: "rich"` so the client can distinguish without re-implementing
/// the tool name list.
fn is_rich_tool(name: &str) -> bool {
matches!(
name,
"Edit"
| "mcp__bash__run"
| "mcp__hyperhive__send"
| "mcp__hyperhive__ask"
| "mcp__hyperhive__answer"
)
}
/// Pre-compute the expandable body for rich tool entries.
///
/// Returns `Some((body, body_type))` where `body_type` tells the frontend
/// which renderer to use:
/// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines)
/// - `"plain"` → `api.details` (plain `<pre>` block)
/// - `"markdown"` → `api.detailsOpenMd` (markdown rendered via marked + `DOMPurify`,
/// default-open; used for message-bearing tools: send, ask, answer)
///
/// Returns `None` for tools that have no body at all.
///
/// **`Write` is intentionally absent**: its `content` field can be megabytes
/// and is one-sided (no `old_string` to diff against), making it too large to
/// inline in every SSE event. The flat `"Write /path"` summary row is
/// sufficient — the operator can open the file directly if they need to inspect
/// the written content.
fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
match name {
// Edit diff: old lines prefixed `- `, new lines prefixed `+ `.
// The summary already carries `-N +M` counts (from `fmt_builtin_tool`),
// so the body is the full colour-coded diff.
"Edit" => {
let old = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("");
let mut body = String::new();
for line in old.lines() {
body.push_str("- ");
body.push_str(line);
body.push('\n');
}
for line in new.lines() {
body.push_str("+ ");
body.push_str(line);
body.push('\n');
}
Some((body, "diff"))
}
// Full bash command — first line is already in `_summary`; the full
// `cmd` (prefixed with `$ `) is the body so multi-line scripts are
// readable on expand.
"mcp__bash__run" => {
let cmd = input.get("cmd").and_then(Value::as_str).unwrap_or("");
if cmd.is_empty() {
None
} else {
Some((format!("$ {cmd}"), "plain"))
}
}
// Message-bearing tools: body is markdown text rendered by the client.
// The ask form (operator reply slot) is still mounted client-side;
// only the raw body text moves to the backend here.
"mcp__hyperhive__send" => {
let body = input.get("body").and_then(Value::as_str).unwrap_or("");
if body.is_empty() {
None
} else {
Some((body.to_owned(), "markdown"))
}
}
"mcp__hyperhive__ask" => {
let q = input.get("question").and_then(Value::as_str).unwrap_or("");
if q.is_empty() {
None
} else {
Some((q.to_owned(), "markdown"))
}
}
"mcp__hyperhive__answer" => {
let a = input.get("answer").and_then(Value::as_str).unwrap_or("");
if a.is_empty() {
None
} else {
Some((a.to_owned(), "markdown"))
}
}
_ => None,
}
}
fn tool_icon(name: &str) -> &'static str {
// Exact-match table first, then prefix/contains fallbacks.
match name {
"mcp__hyperhive__send" => "📤",
"mcp__hyperhive__recv" => "📥",
"mcp__hyperhive__ask" => "",
"mcp__hyperhive__answer" => "✍️",
"mcp__hyperhive__remind" => "",
"mcp__hyperhive__set_status" => "🏷️",
"mcp__hyperhive__get_loose_ends" => "🪢",
"mcp__hyperhive__cancel_loose_end" => "✂️",
"mcp__hyperhive__ack_until" => "",
"mcp__hyperhive__get_agent_meta" => "",
"mcp__hyperhive__request_next_turn" => "",
"mcp__hyperhive__restart" => "",
"mcp__hyperhive__kill" => "⏹️",
"mcp__hyperhive__start" => "▶️",
"mcp__hyperhive__update" => "🔄",
"mcp__hyperhive__list_containers"
| "mcp__matrix__list_rooms"
| "mcp__matrix__list_room_members"
| "mcp__matrix__list_invites" => "📋",
"mcp__hyperhive__get_logs" | "mcp__hyperhive__get_host_journal" => "📜",
"mcp__matrix__read_room" | "Read" => "📖",
"mcp__matrix__mark_read" => "👁️",
"mcp__bash__kill" => "🛑",
"Write" => "💾",
"Edit" => "✏️",
"Glob" | "Grep" => "🔍",
_ => tool_icon_fallback(name),
}
}
fn tool_icon_fallback(name: &str) -> &'static str {
if name.starts_with("mcp__matrix__") {
"💬"
} else if name.starts_with("mcp__bash__") {
"🖥️"
} else if name.contains("schedule") {
"⏱️"
} else if name.starts_with("mcp__hyperhive__request_") {
"📦"
} else {
"🔧"
}
}
// ---------------------------------------------------------------------------
// tool_use summary formatter — dispatch to per-family sub-functions
// ---------------------------------------------------------------------------
fn fmt_tool_use(name: &str, input: &Value) -> String {
// Short name: strip the MCP server prefix for display.
let short = if let Some(rest) = name.strip_prefix("mcp__hyperhive__") {
format!("{rest}*")
} else if let Some(rest) = name.strip_prefix("mcp__bash__") {
format!("{rest}*")
} else if let Some(rest) = name.strip_prefix("mcp__matrix__") {
format!("{rest}*")
} else {
name.to_owned()
};
if name.starts_with("mcp__hyperhive__") {
fmt_hyperhive_tool(name, &short, input)
} else if name.starts_with("mcp__bash__") {
fmt_bash_tool(name, &short, input)
} else if name.starts_with("mcp__matrix__") {
fmt_matrix_tool(name, &short, input)
} else {
fmt_builtin_tool(name, &short, input)
}
}
/// Built-in claude tools: Read/Write/Edit/Glob/Grep/Bash/TodoWrite and any
/// unknown tool that doesn't carry a known MCP server prefix.
fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"Read" | "Write" => format!("{short} {}", sv(input, "file_path")),
"Edit" => {
let path = sv(input, "file_path");
let old = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new_n = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("")
.lines()
.count();
if old.is_empty() {
format!("{short} {path} · +{new_n}")
} else {
let old_n = old.lines().count();
format!("{short} {path} · -{old_n} +{new_n}")
}
}
"Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")),
"Bash" => {
let bg = if input
.get("run_in_background")
.and_then(Value::as_bool)
.unwrap_or(false)
{
" [bg]"
} else {
""
};
format!("{short}{bg} $ {}", sv(input, "command"))
}
"TodoWrite" => {
let n = input
.get("todos")
.and_then(Value::as_array)
.map_or(0, Vec::len);
format!("{short} ({n} items)")
}
_ => fmt_args_generic(short, input),
}
}
/// Summary for message-bearing hyperhive tools (send / ask / answer).
///
/// Format: `"{short} → {recipient}"` or `"{short} #{id}"` with ` · NL`
/// appended when the body spans multiple lines.
fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__hyperhive__send" => {
let to = sv(input, "to");
let lines = sv(input, "body").lines().count();
if lines > 1 {
format!("{short}{to} · {lines}L")
} else {
format!("{short}{to}")
}
}
"mcp__hyperhive__ask" => {
let to = input
.get("to")
.and_then(Value::as_str)
.unwrap_or("operator");
let lines = sv(input, "question").lines().count();
if lines > 1 {
format!("{short}{to} · {lines}L")
} else {
format!("{short}{to}")
}
}
"mcp__hyperhive__answer" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
let lines = sv(input, "answer").lines().count();
if lines > 1 {
format!("{short} #{id} · {lines}L")
} else {
format!("{short} #{id}")
}
}
_ => fmt_args_generic(short, input),
}
}
/// `mcp__hyperhive__*` tools.
fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__hyperhive__send" | "mcp__hyperhive__ask" | "mcp__hyperhive__answer" => {
fmt_hyperhive_message_tool(name, short, input)
}
"mcp__hyperhive__recv" => {
let mut parts = Vec::new();
if let Some(w) = input.get("wait_seconds").and_then(Value::as_u64) {
parts.push(format!("wait {w}s"));
}
if let Some(m) = input.get("max").and_then(Value::as_u64) {
parts.push(format!("max {m}"));
}
if parts.is_empty() {
format!("{short}()")
} else {
format!("{short} {}", parts.join(" · "))
}
}
"mcp__hyperhive__kill"
| "mcp__hyperhive__restart"
| "mcp__hyperhive__start"
| "mcp__hyperhive__update"
| "mcp__hyperhive__request_init_config" => format!("{short} {}", sv(input, "name")),
"mcp__hyperhive__ack_until" => {
let up_to = input
.get("up_to")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
format!("{short}{up_to}")
}
"mcp__hyperhive__get_logs" => {
let lines = input
.get("lines")
.and_then(Value::as_u64)
.map(|n| format!(" · {n}L"))
.unwrap_or_default();
format!("{short} {}{lines}", sv(input, "agent"))
}
"mcp__hyperhive__get_host_journal" => {
let mut parts = Vec::new();
if let Some(c) = input.get("container").and_then(Value::as_str) {
parts.push(c.to_owned());
} else if let Some(u) = input.get("unit").and_then(Value::as_str) {
parts.push(u.to_owned());
}
if let Some(g) = input.get("grep").and_then(Value::as_str) {
parts.push(format!("/{g}/"));
}
if let Some(l) = input.get("lines").and_then(Value::as_u64) {
parts.push(format!("{l}L"));
}
if parts.is_empty() {
format!("{short}()")
} else {
format!("{short} {}", parts.join(" · "))
}
}
"mcp__hyperhive__remind" => fmt_hyperhive_remind(short, input),
"mcp__hyperhive__request_update_meta_inputs"
| "mcp__hyperhive__list_schedules"
| "mcp__hyperhive__cancel_schedule"
| "mcp__hyperhive__fire_schedule_now"
| "mcp__hyperhive__edit_schedule"
| "mcp__hyperhive__request_schedule_prompt" => {
fmt_hyperhive_schedule_tool(name, short, input)
}
"mcp__hyperhive__set_status" => {
format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60))
}
"mcp__hyperhive__get_loose_ends" => {
let agent = input
.get("agent")
.and_then(Value::as_str)
.map_or_else(|| "()".to_owned(), |a| format!(" [{a}]"));
format!("{short}{agent}")
}
"mcp__hyperhive__get_agent_meta" => {
let name_part = input
.get("name")
.and_then(Value::as_str)
.map_or_else(|| "()".to_owned(), |n| format!(" {n}"));
format!("{short}{name_part}")
}
"mcp__hyperhive__cancel_loose_end" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
format!("{short} {} #{id}", sv(input, "kind"))
}
_ => fmt_args_generic(short, input),
}
}
fn fmt_hyperhive_remind(short: &str, input: &Value) -> String {
let when = if let Some(s) = input.get("delay_seconds").and_then(Value::as_u64) {
if s < 60 {
format!("+{s}s")
} else if s < 3_600 {
format!("+{}m", s / 60)
} else {
#[allow(clippy::cast_precision_loss)]
let h_f = s as f64 / 3_600.0;
format!("+{h_f:.1}h")
}
} else if let Some(ts) = input.get("at_unix_timestamp").and_then(Value::as_u64) {
let h = (ts % 86_400) / 3_600;
let m = (ts % 3_600) / 60;
format!("at {h:02}:{m:02}Z")
} else {
String::new()
};
let msg_raw = input
.get("message")
.or_else(|| input.get("file_path"))
.and_then(Value::as_str)
.unwrap_or("");
let msg = trim_str(&msg_raw.replace(char::is_whitespace, " "), 60);
let when_part = if when.is_empty() {
String::new()
} else {
format!(" {when}")
};
let msg_part = if msg.is_empty() {
String::new()
} else {
format!(" \"{msg}\"")
};
format!("{short}{when_part}{msg_part}")
}
fn fmt_hyperhive_edit_schedule(short: &str, input: &Value) -> String {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
let mut parts = vec![format!("#{id}")];
if input.get("body").is_some() {
parts.push("body".to_owned());
}
if input.get("interval_seconds").is_some() {
parts.push("interval".to_owned());
}
if input.get("next_fire_at_unix").is_some() {
parts.push("next".to_owned());
}
if let Some(a) = input.get("targets_add").and_then(Value::as_array)
&& !a.is_empty()
{
parts.push(format!("+{} tgt", a.len()));
}
if let Some(r) = input.get("targets_remove").and_then(Value::as_array)
&& !r.is_empty()
{
parts.push(format!("-{} tgt", r.len()));
}
format!("{short} {}", parts.join(" · "))
}
/// Schedule-management hyperhive tools (list/cancel/fire/edit/request).
fn fmt_hyperhive_schedule_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__hyperhive__request_update_meta_inputs" => {
let ins = match input.get("inputs").and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => {
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).take(4).collect();
let tail = if arr.len() > 4 { ", …" } else { "" };
format!("[{}{}]", names.join(", "), tail)
}
_ => "all".to_owned(),
};
format!("{short} {ins}")
}
"mcp__hyperhive__list_schedules" => format!("{short}()"),
"mcp__hyperhive__cancel_schedule" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
let tgts = match input.get("targets").and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => {
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).collect();
format!(" [{}]", names.join(", "))
}
_ => " all".to_owned(),
};
format!("{short} #{id}{tgts}")
}
"mcp__hyperhive__fire_schedule_now" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
format!("{short} #{id}")
}
"mcp__hyperhive__edit_schedule" => fmt_hyperhive_edit_schedule(short, input),
"mcp__hyperhive__request_schedule_prompt" => {
let tgts = match input.get("targets").and_then(Value::as_array) {
Some(arr) => arr
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", "),
None => "?".to_owned(),
};
let when = input
.get("first_fire_at_unix")
.and_then(Value::as_u64)
.map_or_else(
|| "?".to_owned(),
|ts| {
let h = (ts % 86_400) / 3_600;
let m = (ts % 3_600) / 60;
format!("{h:02}:{m:02}Z")
},
);
let recur = input
.get("interval_seconds")
.and_then(Value::as_u64)
.map(|s| format!(" +{s}s"))
.unwrap_or_default();
format!("{short}{tgts} at {when}{recur}")
}
_ => fmt_args_generic(short, input),
}
}
/// `mcp__bash__*` tools.
fn fmt_bash_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__bash__run" => {
let cmd = sv(input, "cmd");
let first = cmd.lines().next().unwrap_or("").trim().to_owned();
format!("{short} $ {}", trim_str(&first, 72))
}
"mcp__bash__status" => {
let wait = input
.get("wait_seconds")
.and_then(Value::as_u64)
.map(|w| format!(" · wait {w}s"))
.unwrap_or_default();
format!("{short} id:{}{wait}", sv(input, "id"))
}
"mcp__bash__kill" => {
let force = if input.get("force").and_then(Value::as_bool).unwrap_or(false) {
" [force]"
} else {
""
};
format!("{short} {}{force}", sv(input, "id"))
}
_ => fmt_args_generic(short, input),
}
}
/// `mcp__matrix__*` tools.
fn fmt_matrix_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__matrix__read_room" => {
let limit = input
.get("limit")
.and_then(Value::as_u64)
.map(|l| format!(" [{l}]"))
.unwrap_or_default();
format!("{short} {}{limit}", fmt_room(&sv(input, "room")))
}
"mcp__matrix__mark_read" | "mcp__matrix__join_room" | "mcp__matrix__download_file" => {
format!("{short} {}", fmt_room(&sv(input, "room")))
}
"mcp__matrix__send_message" | "mcp__matrix__send_reply" => format!(
"{short}{}: {}",
fmt_room(&sv(input, "room")),
json_str(&trim_str(&sv(input, "body"), 50))
),
"mcp__matrix__send_dm" => format!(
"{short}{}: {}",
fmt_user(&sv(input, "user_id")),
json_str(&trim_str(&sv(input, "body"), 50))
),
"mcp__matrix__send_reaction" => format!(
"{short} {} {}",
fmt_room(&sv(input, "room")),
sv(input, "key")
),
"mcp__matrix__open_dm" => format!("{short} {}", fmt_user(&sv(input, "user_id"))),
"mcp__matrix__invite_user" => format!(
"{short} {}{}",
fmt_user(&sv(input, "user_id")),
fmt_room(&sv(input, "room"))
),
_ => fmt_args_generic(short, input),
}
}
// ---------------------------------------------------------------------------
// generic fallback formatter (port of JS fmtArgsGeneric)
// ---------------------------------------------------------------------------
fn fmt_args_generic(name: &str, input: &Value) -> String {
let Some(obj) = input.as_object() else {
return format!("{name}()");
};
if obj.is_empty() {
return format!("{name}()");
}
let keys: Vec<&String> = obj.keys().collect();
if keys.len() == 1 {
let k = keys[0];
let v = &obj[k];
if let Some(s) = v.as_str() {
return format!("{name} {k}: {}", json_str(&trim_str(s, 100)));
}
if v.is_number() || v.is_boolean() {
return format!("{name} {k}: {v}");
}
}
let pretty: Vec<String> = keys
.iter()
.take(4)
.map(|k| {
let v = &obj[*k];
if v.is_null() {
format!("{k}: null")
} else if let Some(s) = v.as_str() {
format!("{k}: {}", json_str(&trim_str(s, 40)))
} else if v.is_number() || v.is_boolean() {
format!("{k}: {v}")
} else if let Some(arr) = v.as_array() {
format!("{k}: [{}]", arr.len())
} else {
format!("{k}: {{}}")
}
})
.collect();
let tail = if keys.len() > 4 {
format!(" …+{}", keys.len() - 4)
} else {
String::new()
};
format!("{name} {}{tail}", pretty.join(" · "))
}
// ---------------------------------------------------------------------------
// string utilities
// ---------------------------------------------------------------------------
/// Format a token count as "1k", "1.2M", etc. (mirrors JS `fmtTok`).
fn fmt_tok(n: u64) -> String {
if n >= 1_000_000 {
// Divide before casting to keep the value in a range where f64
// has enough precision for one decimal place (≤ ~9×10^12 tokens).
#[allow(clippy::cast_precision_loss)]
let m = (n / 1_000) as f64 / 1_000.0;
format!("{m:.1}M")
} else if n >= 1_000 {
format!("{}k", n / 1_000)
} else {
n.to_string()
}
}
/// Shorten a matrix room id/alias for display.
fn fmt_room(r: &str) -> String {
if r.starts_with('!') {
// Room id: keep only the local part before the colon (up to 9 chars).
// Use chars().take() so we never slice on a non-ASCII byte boundary.
let colon = r.find(':').unwrap_or(r.len());
r.chars().take(colon.min(9)).collect()
} else if r.starts_with('#') {
// Alias: keep `#name` part before the server.
r.split(':').next().unwrap_or(r).to_owned()
} else {
trim_str(r, 20)
}
}
/// Shorten `@user:server` → `@user`.
fn fmt_user(u: &str) -> String {
let end = u.find(':').unwrap_or(u.len());
u[..end].to_owned()
}
/// Extract a string field from a JSON object, defaulting to `""`.
fn sv(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
}
/// Trim to `max` *characters* (not bytes), collapsing whitespace first.
fn trim_str(s: &str, max: usize) -> String {
let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
let mut chars = collapsed.chars();
let trimmed: String = chars.by_ref().take(max).collect();
if chars.next().is_some() {
format!("{trimmed}")
} else {
trimmed
}
}
/// Serialize a string as a JSON string literal (e.g. `"hello"`) for display
/// in summary lines. Falls back to the raw string if serialization fails.
fn json_str(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| s.to_owned())
}