json input format + required room_id on all room-scoped tools

This commit is contained in:
Damocles 2026-05-01 12:46:15 +02:00
parent ef461797ad
commit cc3451eef3
5 changed files with 260 additions and 241 deletions

View file

@ -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<String>,
}
#[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<u32>,
/// Room to look in. Omit for the source room (the common case).
#[serde(default)]
room_id: Option<String>,
}
// ---------------------------------------------------------------------------
@ -161,14 +167,12 @@ struct FetchEventParams {
struct MatrixBridge {
socket: Mutex<UnixStream>,
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<SendMessageParams>,
) -> Result<CallToolResult, McpError> {
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<SendReactionParams>,
) -> Result<CallToolResult, McpError> {
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<SendReplyParams>,
) -> Result<CallToolResult, McpError> {
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<FetchEventParams>,
) -> Result<CallToolResult, McpError> {
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);
})?;

View file

@ -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<String>,
previously_seen: Vec<WireEvent>,
new_events: Vec<WireEvent>,
}
fn build_turn(
source_room: &OwnedRoomId,
room_name: &str,
timeline: &[TimelineItem],
seen_idx: usize,
read_markers: &HashMap<OwnedEventId, Vec<OwnedUserId>>,
) -> 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/<user_id>/notes.md for each]"
)
.unwrap();
for s in &senders {
writeln!(prompt, " {s}").unwrap();
}
}
let people_in_room: Vec<String> = 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<WireEvent> = timeline[..seen]
.iter()
.map(|i| wire_event_from(i, read_markers))
.collect();
let new_events: Vec<WireEvent> = 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,
}
}
pub fn wire_event_from(
item: &TimelineItem,
read_markers: &HashMap<OwnedEventId, Vec<OwnedUserId>>,
) -> WireEvent {
match item {
TimelineItem::Message {
event_id,
sender,
body,
is_self,
ts,
in_reply_to,
} => {
let read_by: Vec<String> = 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(),
},
}
}
writeln!(prompt, "\n[new events - respond to these]").unwrap();
if new.is_empty() {
writeln!(prompt, "(none)").unwrap();
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<String, McpServer>,
}
#[derive(Debug, Serialize)]
struct McpServer {
command: String,
args: Vec<String>,
env: std::collections::BTreeMap<String, String>,
}
/// 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<String> {
fn build_mcp_config(socket_path: &Path) -> anyhow::Result<String> {
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")
}

View file

@ -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<MemberInfo> = 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<WireEvent> = 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<serde_json::Value> {
let render = |raw: &matrix_sdk::deserialized_responses::TimelineEvent| -> Option<WireEvent> {
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<WireEvent> = 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<WireEvent> = 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,
})
}

View file

@ -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<OwnedEve
None
}
/// Render one timeline item into the prompt.
/// Messages: `[ts] $eid... [(you) ]@user: body [read by: ...]`
/// Reactions: `[ts] [(you) ]@user reacted to $eid... with KEY`
pub fn render_timeline_item(
prompt: &mut String,
item: &TimelineItem,
read_markers: &HashMap<OwnedEventId, Vec<OwnedUserId>>,
) {
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<String> = 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.
///

View file

@ -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<String>,
read_by: Vec<String>,
},
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<WireEvent>,
pub context_before: Vec<WireEvent>,
pub earlier_handle: Option<String>,
}
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;