json input format + required room_id on all room-scoped tools
This commit is contained in:
parent
ef461797ad
commit
cc3451eef3
5 changed files with 260 additions and 241 deletions
|
|
@ -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);
|
||||
})?;
|
||||
|
|
|
|||
189
src/claude.rs
189
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<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,
|
||||
}
|
||||
}
|
||||
|
||||
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<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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
125
src/socket.rs
125
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<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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
47
src/types.rs
47
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<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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue