//! 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 `
` with `_summary` /// as the header and `_body` as the expanded text fn system_fields(v: &Value, subtype: &str) -> (&'static str, Option, Option) { 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::>() .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) = body { obj.insert("_body".to_owned(), json!(b)); } } // --------------------------------------------------------------------------- // 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, "Write" | "Edit" | "mcp__bash__run" | "mcp__hyperhive__send" | "mcp__hyperhive__ask" | "mcp__hyperhive__answer" ) } /// Pre-compute the expandable body text for tools that have one. /// /// Returns `Some(body)` when the tool has a meaningful multi-line body that /// the frontend can display in a `
` block without tool-specific JS. /// `None` for tools whose body is computed client-side (diffs, markdown) or /// that have no body at all. fn rich_tool_body(name: &str, input: &Value) -> Option { match name { // 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}")) } } _ => 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" | "Edit" => format!("{short} {}", sv(input, "file_path")), "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), } } /// `mcp__hyperhive__*` tools. fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String { match name { "mcp__hyperhive__send" => { let body = trim_str(&sv(input, "body"), 80); format!("{short} → {}: {}", sv(input, "to"), json_str(&body)) } "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::>() .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 = 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::>().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()) }