From cc3451eef3ab9c062965e771d33e7d97384b0e16 Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 1 May 2026 12:46:15 +0200 Subject: [PATCH] json input format + required room_id on all room-scoped tools --- src/bin/mcp.rs | 66 ++++++++--------- src/claude.rs | 193 +++++++++++++++++++++++++++++++++--------------- src/socket.rs | 125 ++++++++++++------------------- src/timeline.rs | 70 ------------------ src/types.rs | 47 ++++++++++++ 5 files changed, 260 insertions(+), 241 deletions(-) diff --git a/src/bin/mcp.rs b/src/bin/mcp.rs index 05fc46a..f37094c 100644 --- a/src/bin/mcp.rs +++ b/src/bin/mcp.rs @@ -83,13 +83,13 @@ use protocol_inline::{DaemonRequest, DaemonResponse}; #[derive(Debug, Deserialize, JsonSchema)] struct SendMessageParams { - /// Message text. Plain text - markdown isn't specially rendered. Keep it - /// short, you're rate-limited and terse is on-character. + /// Target room ID like `!abc:server.com`. Take this from the `room_id` + /// field of the matrix_turn JSON the daemon gave you, or from + /// list_rooms / get_room_history. + room_id: String, + /// Message text. Markdown is rendered (italic/bold/code/quote/link). Keep + /// it short, you're rate-limited and terse is on-character. body: String, - /// Target room ID like `!abc:server.com`. Omit to send to the room that - /// triggered this invocation (the common case). - #[serde(default)] - room_id: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -103,9 +103,12 @@ struct SendDmParams { #[derive(Debug, Deserialize, JsonSchema)] struct SendReactionParams { - /// Event ID of the message to react to. Shortened form from the prompt - /// (`$abc12345…`) is fine - resolved by prefix match against recent - /// timeline. + /// Room ID like `!abc:server.com`. Take this from the `room_id` field + /// of the matrix_turn JSON. + room_id: String, + /// Event ID of the message to react to. Use the full ID from the JSON + /// (`event_id` field). The shortened `event_id_short` form also works - + /// resolved by prefix match against recent timeline. event_id: String, /// The actual emoji character to react with, e.g. `🔥` or `👀` or `❤️`. /// Not a keyword name like "fire". @@ -120,11 +123,14 @@ struct ListRoomMembersParams { #[derive(Debug, Deserialize, JsonSchema)] struct SendReplyParams { - /// Event ID of the message you're replying to. Shortened form from the - /// prompt (`$abc12345…`) is fine. + /// Room ID like `!abc:server.com`. Take this from the `room_id` field + /// of the matrix_turn JSON. + room_id: String, + /// Event ID of the message you're replying to. Use the full ID from the + /// JSON. Shortened forms also work via prefix match. event_id: String, - /// Reply text. Matrix clients render the original above as a quote, so - /// don't repeat its content - just reply. + /// Reply text. Markdown rendered. Matrix clients render the original + /// above as a quote, so don't repeat its content - just reply. body: String, } @@ -141,6 +147,9 @@ struct GetRoomHistoryParams { #[derive(Debug, Deserialize, JsonSchema)] struct FetchEventParams { + /// Room to look in. Required - take this from the `room_id` field of + /// the matrix_turn JSON, or from list_rooms. + room_id: String, /// Event ID to fetch. Shortened form from the prompt (`$abc12345…`) or a /// full ID. Use this to dereference `[reply to $...]` markers or any /// event_id referenced in chat that isn't in your current window. @@ -150,9 +159,6 @@ struct FetchEventParams { /// conversation around this". #[serde(default)] context_before: Option, - /// Room to look in. Omit for the source room (the common case). - #[serde(default)] - room_id: Option, } // --------------------------------------------------------------------------- @@ -161,14 +167,12 @@ struct FetchEventParams { struct MatrixBridge { socket: Mutex, - source_room: String, } impl MatrixBridge { - fn new(socket: UnixStream, source_room: String) -> Self { + fn new(socket: UnixStream) -> Self { Self { socket: Mutex::new(socket), - source_room, } } @@ -218,17 +222,14 @@ impl MatrixBridge { #[tool_router(server_handler)] impl MatrixBridge { - #[tool(description = "Send a top-level message to a Matrix room. The default target is the room that triggered this invocation. For replies to a specific message in a busy room, use send_reply instead. For private 1:1, use send_dm.")] + #[tool(description = "Send a top-level message to a Matrix room. For replies to a specific message in a busy room, use send_reply instead. For private 1:1, use send_dm.")] async fn send_message( &self, Parameters(params): Parameters, ) -> Result { - let room_id = params - .room_id - .unwrap_or_else(|| self.source_room.clone()); let resp = self .call(&DaemonRequest::SendMessage { - room_id, + room_id: params.room_id, body: params.body, }) .await?; @@ -249,14 +250,14 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "React to a specific message in the source room with an emoji. Lower friction than a reply when you just want to acknowledge or signal. Reactions are visible to everyone in the room.")] + #[tool(description = "React to a specific message in a Matrix room with an emoji. Lower friction than a reply when you just want to acknowledge or signal. Reactions are visible to everyone in the room.")] async fn send_reaction( &self, Parameters(params): Parameters, ) -> Result { let resp = self .call(&DaemonRequest::SendReaction { - room_id: self.source_room.clone(), + room_id: params.room_id, event_id: params.event_id, key: params.key, }) @@ -283,14 +284,14 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "Reply to a specific message in the source room with proper m.in_reply_to threading. Matrix clients render the original message as a quote above your reply, so don't repeat its content. Use this when there are multiple parallel conversations and a top-level message would be ambiguous - otherwise prefer send_message.")] + #[tool(description = "Reply to a specific message in a Matrix room with proper m.in_reply_to threading. Matrix clients render the original message as a quote above your reply, so don't repeat its content. Use this when there are multiple parallel conversations and a top-level message would be ambiguous - otherwise prefer send_message.")] async fn send_reply( &self, Parameters(params): Parameters, ) -> Result { let resp = self .call(&DaemonRequest::SendReply { - room_id: self.source_room.clone(), + room_id: params.room_id, event_id: params.event_id, body: params.body, }) @@ -317,10 +318,9 @@ impl MatrixBridge { &self, Parameters(params): Parameters, ) -> Result { - let room_id = params.room_id.unwrap_or_else(|| self.source_room.clone()); let resp = self .call(&DaemonRequest::FetchEvent { - room_id, + room_id: params.room_id, event_id: params.event_id, context_before: params.context_before, }) @@ -347,15 +347,13 @@ async fn main() -> Result<()> { let socket_path = std::env::var("DAMOCLES_SOCKET").context("DAMOCLES_SOCKET env var not set")?; - let source_room = - std::env::var("DAMOCLES_SOURCE_ROOM").context("DAMOCLES_SOURCE_ROOM env var not set")?; - tracing::info!(%socket_path, %source_room, "damocles-mcp starting"); + tracing::info!(%socket_path, "damocles-mcp starting"); let socket = UnixStream::connect(&socket_path) .with_context(|| format!("failed to connect to daemon socket at {socket_path}"))?; - let bridge = MatrixBridge::new(socket, source_room); + let bridge = MatrixBridge::new(socket); let service = bridge.serve(stdio()).await.inspect_err(|e| { tracing::error!("mcp serve error: {:?}", e); })?; diff --git a/src/claude.rs b/src/claude.rs index 4a68b67..c9d4d11 100644 --- a/src/claude.rs +++ b/src/claude.rs @@ -1,20 +1,17 @@ use std::collections::HashMap; -use std::fmt::Write as _; use std::path::Path; use anyhow::{Context, bail}; use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId}; -use serde_json::json; +use serde::Serialize; use crate::paths; -use crate::timeline::render_timeline_item; -use crate::types::TimelineItem; +use crate::timeline::format_ts; +use crate::types::{TimelineItem, WireEvent}; -/// Invoke claude with MCP tools for Matrix interaction. -/// -/// Instead of parsing `=== type` output, the shard calls MCP tools -/// (send_message, send_reaction, etc.) which the daemon handles via the Unix -/// socket. Any text claude prints to stdout is logged as internal thought. +/// Invoke claude with MCP tools. The shard receives a JSON `matrix_turn` +/// describing the room and new events, and calls MCP tools (which carry an +/// explicit room_id) for any actions. Claude's stdout is logged as thought. pub async fn invoke_claude( source_room: &OwnedRoomId, room_name: &str, @@ -27,10 +24,13 @@ pub async fn invoke_claude( let identity_dir = paths::identity_dir(); let identity_str = identity_dir.to_string_lossy(); - let prompt = build_prompt(source_room, room_name, timeline, seen_idx, read_markers); + let turn = build_turn(source_room, room_name, timeline, seen_idx, read_markers); + let prompt = format!( + "{TURN_PREAMBLE}\n\n```json\n{}\n```\n", + serde_json::to_string_pretty(&turn).unwrap() + ); - // Write MCP config pointing to our bridge binary + daemon socket - let mcp_config = build_mcp_config(socket_path, source_room)?; + let mcp_config = build_mcp_config(socket_path)?; let mcp_config_path = paths::state_dir().join("mcp.json"); tokio::fs::write(&mcp_config_path, &mcp_config).await?; @@ -84,7 +84,6 @@ pub async fn invoke_claude( tracing::warn!("claude stderr: {stderr}"); } - // With MCP, stdout is just the shard's internal monologue - log it let text = stdout.trim(); if !text.is_empty() { tracing::info!( @@ -97,23 +96,28 @@ pub async fn invoke_claude( Ok(()) } -fn build_prompt( +const TURN_PREAMBLE: &str = "New matrix events for you. JSON envelope follows. \ +The room_id and other fields are explicit - use them in your tool calls."; + +#[derive(Debug, Serialize)] +struct MatrixTurn { + #[serde(rename = "type")] + kind: &'static str, + room_id: String, + room_name: String, + room_notes_path: String, + people_in_room: Vec, + previously_seen: Vec, + new_events: Vec, +} + +fn build_turn( source_room: &OwnedRoomId, room_name: &str, timeline: &[TimelineItem], seen_idx: usize, read_markers: &HashMap>, -) -> String { - let mut prompt = String::new(); - writeln!(prompt, "[room_id: {source_room}]").unwrap(); - writeln!(prompt, "[room_name: {room_name}]").unwrap(); - writeln!( - prompt, - "[room notes path: ../rooms/{source_room}/notes.md (create dir if needed)]" - ) - .unwrap(); - - // Collect unique non-self participants +) -> MatrixTurn { let mut senders: Vec<&OwnedUserId> = timeline .iter() .filter(|t| !t.is_self()) @@ -121,58 +125,125 @@ fn build_prompt( .collect(); senders.sort(); senders.dedup(); - if !senders.is_empty() { - writeln!( - prompt, - "\n[people in this room - check ../people//notes.md for each]" - ) - .unwrap(); - for s in &senders { - writeln!(prompt, " {s}").unwrap(); - } - } + let people_in_room: Vec = senders.iter().map(|s| s.to_string()).collect(); let seen = seen_idx.min(timeline.len()); - let (old, new) = timeline.split_at(seen); + let previously_seen: Vec = timeline[..seen] + .iter() + .map(|i| wire_event_from(i, read_markers)) + .collect(); + let new_events: Vec = timeline[seen..] + .iter() + .map(|i| wire_event_from(i, read_markers)) + .collect(); - if !old.is_empty() { - writeln!(prompt, "\n[previously seen events - for context]").unwrap(); - for item in old { - render_timeline_item(&mut prompt, item, read_markers); - } + MatrixTurn { + kind: "matrix_turn", + room_id: source_room.as_str().to_owned(), + room_name: room_name.to_owned(), + room_notes_path: format!("../rooms/{source_room}/notes.md"), + people_in_room, + previously_seen, + new_events, } +} - writeln!(prompt, "\n[new events - respond to these]").unwrap(); - if new.is_empty() { - writeln!(prompt, "(none)").unwrap(); +pub fn wire_event_from( + item: &TimelineItem, + read_markers: &HashMap>, +) -> WireEvent { + match item { + TimelineItem::Message { + event_id, + sender, + body, + is_self, + ts, + in_reply_to, + } => { + let read_by: Vec = read_markers + .get(event_id) + .map(|rs| { + let mut sorted = rs.clone(); + sorted.sort(); + sorted.iter().map(ToString::to_string).collect() + }) + .unwrap_or_default(); + WireEvent::Message { + event_id: event_id.as_str().to_owned(), + event_id_short: short_eid(event_id.as_str()), + sender: sender.as_str().to_owned(), + is_self: *is_self, + ts: *ts, + ts_human: format!("{} UTC", format_ts(*ts)), + body: body.clone(), + in_reply_to: in_reply_to.as_ref().map(|e| e.as_str().to_owned()), + read_by, + } + } + TimelineItem::Reaction { + sender, + target_event_id, + key, + is_self, + ts, + } => WireEvent::Reaction { + sender: sender.as_str().to_owned(), + is_self: *is_self, + ts: *ts, + ts_human: format!("{} UTC", format_ts(*ts)), + target_event_id: target_event_id.as_str().to_owned(), + target_event_id_short: short_eid(target_event_id.as_str()), + key: key.clone(), + }, + } +} + +pub fn short_eid(s: &str) -> String { + let prefix: String = s.chars().take(9).collect(); + if s.len() > 9 { + format!("{prefix}…") } else { - for item in new { - render_timeline_item(&mut prompt, item, read_markers); - } + prefix } +} - prompt +#[derive(Debug, Serialize)] +struct McpConfig { + #[serde(rename = "mcpServers")] + mcp_servers: std::collections::BTreeMap, +} + +#[derive(Debug, Serialize)] +struct McpServer { + command: String, + args: Vec, + env: std::collections::BTreeMap, } /// Build the MCP config JSON that tells claude how to launch damocles-mcp. -fn build_mcp_config(socket_path: &Path, source_room: &OwnedRoomId) -> anyhow::Result { +fn build_mcp_config(socket_path: &Path) -> anyhow::Result { let mcp_bin = std::env::current_exe()? .parent() .context("no parent dir for current exe")? .join("damocles-mcp"); - let config = json!({ - "mcpServers": { - "matrix": { - "command": mcp_bin.to_string_lossy(), - "args": [], - "env": { - "DAMOCLES_SOCKET": socket_path.to_string_lossy(), - "DAMOCLES_SOURCE_ROOM": source_room.as_str() - } - } - } - }); + let mut env = std::collections::BTreeMap::new(); + env.insert( + "DAMOCLES_SOCKET".to_owned(), + socket_path.to_string_lossy().into_owned(), + ); + let mut mcp_servers = std::collections::BTreeMap::new(); + mcp_servers.insert( + "matrix".to_owned(), + McpServer { + command: mcp_bin.to_string_lossy().into_owned(), + args: Vec::new(), + env, + }, + ); + + let config = McpConfig { mcp_servers }; serde_json::to_string_pretty(&config).context("serialize mcp config") } diff --git a/src/socket.rs b/src/socket.rs index fbaf5ab..b1ac783 100644 --- a/src/socket.rs +++ b/src/socket.rs @@ -12,7 +12,8 @@ use matrix_sdk::{ }, }, }; -use serde_json::json; +use crate::claude::{short_eid, wire_event_from}; +use crate::types::{FetchEventResult, MemberInfo, RoomInfo, WireEvent}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -166,10 +167,10 @@ async fn list_rooms(client: &Client) -> DaemonResponse { .display_name() .await .map_or_else(|_| room.room_id().to_string(), |n| n.to_string()); - rooms.push(json!({ - "room_id": room.room_id().as_str(), - "name": name, - })); + rooms.push(RoomInfo { + room_id: room.room_id().as_str().to_owned(), + name, + }); } DaemonResponse::ok(rooms) } @@ -186,13 +187,11 @@ async fn list_room_members(client: &Client, room_id: &str) -> DaemonResponse { Ok(m) => m, Err(e) => return DaemonResponse::err(format!("failed to list members: {e}")), }; - let list: Vec<_> = members + let list: Vec = members .iter() - .map(|m| { - json!({ - "user_id": m.user_id().as_str(), - "display_name": m.display_name().unwrap_or_default(), - }) + .map(|m| MemberInfo { + user_id: m.user_id().as_str().to_owned(), + display_name: m.display_name().unwrap_or_default().to_owned(), }) .collect(); DaemonResponse::ok(list) @@ -282,48 +281,17 @@ async fn get_room_history( }; } - let items: Vec<_> = tl.iter().map(timeline_item_to_json).collect(); + let read_markers = timeline::compute_read_markers(&room, &tl, &own_user).await; + let items: Vec = tl + .iter() + .map(|i| wire_event_from(i, &read_markers)) + .collect(); DaemonResponse::ok(items) } else { DaemonResponse::err("event cache not available".to_owned()) } } -fn timeline_item_to_json(item: &crate::types::TimelineItem) -> serde_json::Value { - match item { - crate::types::TimelineItem::Message { - event_id, - sender, - body, - is_self, - ts, - in_reply_to, - } => json!({ - "kind": "message", - "event_id": event_id.as_str(), - "sender": sender.as_str(), - "body": body, - "is_self": is_self, - "ts": ts, - "in_reply_to": in_reply_to.as_ref().map(|e| e.as_str()), - }), - crate::types::TimelineItem::Reaction { - sender, - target_event_id, - key, - is_self, - ts, - } => json!({ - "kind": "reaction", - "sender": sender.as_str(), - "target_event_id": target_event_id.as_str(), - "key": key, - "is_self": is_self, - "ts": ts, - }), - } -} - /// Fetch a specific event by ID via the homeserver `/context` endpoint. /// Returns the event plus `context_before` events before it. Includes one /// extra event as `earlier_handle` so the shard can page further backward @@ -381,7 +349,7 @@ async fn fetch_event( Err(e) => return DaemonResponse::err(format!("event_with_context failed: {e}")), }; - let render = |raw: &matrix_sdk::deserialized_responses::TimelineEvent| -> Option { + let render = |raw: &matrix_sdk::deserialized_responses::TimelineEvent| -> Option { let deserialized = raw.raw().deserialize().ok()?; let AnySyncTimelineEvent::MessageLike(msg) = deserialized else { return None; @@ -401,47 +369,52 @@ async fn fetch_event( } _ => None, }; - Some(json!({ - "kind": "message", - "event_id": orig.event_id.as_str(), - "sender": orig.sender.as_str(), - "body": text.body, - "is_self": orig.sender == own_user, - "ts": ts, - "in_reply_to": in_reply_to, - })) + Some(WireEvent::Message { + event_id: orig.event_id.as_str().to_owned(), + event_id_short: short_eid(orig.event_id.as_str()), + sender: orig.sender.as_str().to_owned(), + is_self: orig.sender == own_user, + ts, + ts_human: format!("{} UTC", crate::timeline::format_ts(ts)), + body: text.body.clone(), + in_reply_to, + read_by: Vec::new(), + }) } matrix_sdk::ruma::events::AnySyncMessageLikeEvent::Reaction( matrix_sdk::ruma::events::SyncMessageLikeEvent::Original(orig), ) => { let ms: u64 = orig.origin_server_ts.0.into(); let ts = (ms / 1000) as i64; - Some(json!({ - "kind": "reaction", - "sender": orig.sender.as_str(), - "target_event_id": orig.content.relates_to.event_id.as_str(), - "key": orig.content.relates_to.key, - "is_self": orig.sender == own_user, - "ts": ts, - })) + Some(WireEvent::Reaction { + sender: orig.sender.as_str().to_owned(), + is_self: orig.sender == own_user, + ts, + ts_human: format!("{} UTC", crate::timeline::format_ts(ts)), + target_event_id: orig.content.relates_to.event_id.as_str().to_owned(), + target_event_id_short: short_eid(orig.content.relates_to.event_id.as_str()), + key: orig.content.relates_to.key.clone(), + }) } _ => None, } }; // events_before is newest-first per matrix /context spec - reverse for chronological - let mut before: Vec<_> = response.events_before.iter().filter_map(render).collect(); + let mut before: Vec = response.events_before.iter().filter_map(render).collect(); before.reverse(); - // The "earlier handle" is the oldest event we got, if we asked for more than 0 - // It's used by the shard to page further back via another fetch_event call + // The "earlier handle" is the oldest event we got, used by the shard + // to page further back via another fetch_event call. let earlier_handle = if context_before > 0 && before.len() > context_before as usize { - before.first().and_then(|e| e.get("event_id").and_then(|v| v.as_str()).map(String::from)) + before.first().map(|e| match e { + WireEvent::Message { event_id, .. } => event_id.clone(), + WireEvent::Reaction { target_event_id, .. } => target_event_id.clone(), + }) } else { None }; - let context_events: Vec<_> = if earlier_handle.is_some() { - // First event is the handle - skip it from the main events list + let context_events: Vec = if earlier_handle.is_some() { before.into_iter().skip(1).collect() } else { before @@ -449,9 +422,9 @@ async fn fetch_event( let target = response.event.as_ref().and_then(render); - DaemonResponse::ok(json!({ - "event": target, - "context_before": context_events, - "earlier_handle": earlier_handle, - })) + DaemonResponse::ok(FetchEventResult { + event: target, + context_before: context_events, + earlier_handle, + }) } diff --git a/src/timeline.rs b/src/timeline.rs index 742adba..6f05297 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::fmt::Write as _; use matrix_sdk::{ Room, @@ -51,17 +50,6 @@ pub fn ts_secs_from(ts: matrix_sdk::ruma::UInt) -> i64 { i64::try_from(ms).unwrap_or(0) / 1000 } -/// Shorten an event id for prompt display: `$abc123def456...` -> `$abc123de`. -pub fn short_event_id(id: &OwnedEventId) -> String { - let s = id.as_str(); - let prefix: String = s.chars().take(9).collect(); - if s.len() > 9 { - format!("{prefix}…") - } else { - prefix - } -} - /// Resolve a (possibly shortened/ellipsized) event id to a full one by /// looking up against the timeline. Returns the matching message's full /// event id if found. @@ -80,64 +68,6 @@ pub fn resolve_event_id(timeline: &[TimelineItem], arg: &str) -> Option>, -) { - match item { - TimelineItem::Message { - event_id, - sender, - body, - is_self, - ts, - in_reply_to, - } => { - let ts_str = format_ts(*ts); - let id = short_event_id(event_id); - let prefix = if *is_self { "(you) " } else { "" }; - let reply_str = match in_reply_to { - Some(target) => format!(" [reply to {}]", short_event_id(target)), - None => String::new(), - }; - let readers_str = match read_markers.get(event_id) { - Some(rs) if !rs.is_empty() => { - let mut sorted = rs.clone(); - sorted.sort(); - let names: Vec = sorted.iter().map(|u| u.to_string()).collect(); - format!(" [read by: {}]", names.join(", ")) - } - _ => String::new(), - }; - writeln!( - prompt, - "[{ts_str}] {id} {prefix}{sender}:{reply_str} {body}{readers_str}" - ) - .unwrap(); - } - TimelineItem::Reaction { - sender, - target_event_id, - key, - is_self, - ts, - } => { - let ts_str = format_ts(*ts); - let id = short_event_id(target_event_id); - let prefix = if *is_self { "(you) " } else { "" }; - writeln!( - prompt, - "[{ts_str}] {prefix}{sender} reacted to {id} with {key}" - ) - .unwrap(); - } - } -} - /// Load the last N timeline items (messages + reactions) from the room's /// persistent event cache. Returns oldest-first. /// diff --git a/src/types.rs b/src/types.rs index d2cef7d..c662177 100644 --- a/src/types.rs +++ b/src/types.rs @@ -6,6 +6,53 @@ use matrix_sdk::{ }; use serde::{Deserialize, Serialize}; +/// Serializable shape for one timeline event, used both in matrix_turn JSON +/// (input to the shard) and tool response JSON (get_room_history, +/// fetch_event). +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum WireEvent { + Message { + event_id: String, + event_id_short: String, + sender: String, + is_self: bool, + ts: i64, + ts_human: String, + body: String, + in_reply_to: Option, + read_by: Vec, + }, + Reaction { + sender: String, + is_self: bool, + ts: i64, + ts_human: String, + target_event_id: String, + target_event_id_short: String, + key: String, + }, +} + +#[derive(Debug, Serialize)] +pub struct RoomInfo { + pub room_id: String, + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct MemberInfo { + pub user_id: String, + pub display_name: String, +} + +#[derive(Debug, Serialize)] +pub struct FetchEventResult { + pub event: Option, + pub context_before: Vec, + pub earlier_handle: Option, +} + pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6"; pub const DEFAULT_MAX_HISTORY: usize = 20; pub const DEFAULT_RATE_LIMIT_PER_MIN: u32 = 1;