From d6d352d2f77f0dfd7280477095a339e785e92e47 Mon Sep 17 00:00:00 2001 From: Damocles Date: Thu, 30 Apr 2026 21:54:39 +0200 Subject: [PATCH] switch to '=== type' single-line headers for multi-doc output (more robust than ambiguous --- frontmatter) --- src/main.rs | 416 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 288 insertions(+), 128 deletions(-) diff --git a/src/main.rs b/src/main.rs index 51d8753..f5a7469 100644 --- a/src/main.rs +++ b/src/main.rs @@ -548,59 +548,72 @@ async fn process_loop(state: Arc>, client: Client) { let new_last_event_id = history.last().map(|(eid, _, _, _, _)| eid.clone()); - match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await { - Ok(Some(response)) => { - let target_room = match &response.target { - ResponseTarget::Room(rid) => client.get_room(rid), - ResponseTarget::Dm(user) => match find_or_create_dm(&client, user).await { - Ok(r) => Some(r), - Err(e) => { - tracing::error!(user = %user, "failed to get/create DM: {e}"); - None - } - }, - }; - let target_label = match &response.target { - ResponseTarget::Room(rid) => rid.to_string(), - ResponseTarget::Dm(user) => format!("dm:{user}"), - }; - if let Some(target_room) = target_room { - let content = RoomMessageEventContent::text_plain(&response.body); - match target_room.send(content).await { - Ok(_) => { - let mut state = state.lock().await; - state.rate_budget = state.rate_budget.saturating_sub(1); - if let Some(eid) = new_last_event_id.clone() { - state.last_shown.insert(room_id.clone(), eid); - } - tracing::info!( - target = %target_label, - "sent response ({} budget remaining)", - state.rate_budget - ); - drop(state); - send_read_receipt(&room, new_last_event_id.clone()).await; - } - Err(e) => tracing::error!("failed to send: {e}"), - } - } else { - tracing::warn!(target = %target_label, "target not available"); - } - } - Ok(None) => { - tracing::debug!(room = %room_id, "claude chose to skip"); - { - let mut state = state.lock().await; - if let Some(eid) = new_last_event_id.clone() { - state.last_shown.insert(room_id.clone(), eid); - } - } - send_read_receipt(&room, new_last_event_id.clone()).await; - } + let docs = match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await { + Ok(d) => d, Err(e) => { tracing::error!(room = %room_id, "claude invocation failed: {e}"); + continue; + } + }; + + let mut sent_any = false; + for doc in docs { + match doc { + ClaudeDoc::Skip => { + tracing::debug!(room = %room_id, "claude doc: skip"); + } + ClaudeDoc::Thought(body) => { + tracing::info!(room = %room_id, thought = %body.chars().take(120).collect::(), "claude doc: thought"); + tracing::trace!("full thought: {body}"); + } + ClaudeDoc::Message { target, body } => { + let target_room = match &target { + ResponseTarget::Room(rid) => client.get_room(rid), + ResponseTarget::Dm(user) => match find_or_create_dm(&client, user).await { + Ok(r) => Some(r), + Err(e) => { + tracing::error!(user = %user, "failed to get/create DM: {e}"); + None + } + }, + }; + let target_label = match &target { + ResponseTarget::Room(rid) => rid.to_string(), + ResponseTarget::Dm(user) => format!("dm:{user}"), + }; + if let Some(target_room) = target_room { + let content = RoomMessageEventContent::text_plain(&body); + match target_room.send(content).await { + Ok(_) => { + let mut state = state.lock().await; + state.rate_budget = state.rate_budget.saturating_sub(1); + tracing::info!( + target = %target_label, + "sent response ({} budget remaining)", + state.rate_budget + ); + sent_any = true; + } + Err(e) => tracing::error!("failed to send: {e}"), + } + } else { + tracing::warn!(target = %target_label, "target not available"); + } + } } } + + // Update last_shown and send read receipt regardless of whether we + // sent a message - the agent saw the messages either way. + { + let mut state = state.lock().await; + if let Some(eid) = new_last_event_id.clone() { + state.last_shown.insert(room_id.clone(), eid); + } + } + send_read_receipt(&room, new_last_event_id).await; + + let _ = sent_any; } } @@ -720,9 +733,18 @@ enum ResponseTarget { Dm(OwnedUserId), } -struct ClaudeResponse { - target: ResponseTarget, - body: String, +/// One document within Claude's multi-doc output. Each doc has its own +/// frontmatter; the daemon routes based on which fields are present. +enum ClaudeDoc { + /// A chat message to send. + Message { + target: ResponseTarget, + body: String, + }, + /// Agent's internal monologue. Not sent to chat. Logged to tracing. + Thought(String), + /// Explicit "do nothing for this slot". Useful as a placeholder. + Skip, } async fn invoke_claude( @@ -731,7 +753,7 @@ async fn invoke_claude( history: &[ChatMessage], seen_idx: usize, model: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let identity_dir = paths::identity_dir(); let identity_str = identity_dir.to_string_lossy(); @@ -826,57 +848,128 @@ async fn invoke_claude( Ok(parse_response(&raw, source_room)) } -fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Option { +/// Parse Claude's stdout into a list of documents. +/// +/// Format: each doc starts with a line `=== [arg]`. Body is everything +/// until the next `===` line or EOF. Types: +/// - `=== thought` → ClaudeDoc::Thought (logged, not sent) +/// - `=== room []` → ClaudeDoc::Message to that room (or source room if no arg) +/// - `=== dm ` → ClaudeDoc::Message as DM +/// - `=== skip` → ClaudeDoc::Skip (no-op) +/// +/// Anything before the first `===` line is treated as a preamble thought. +/// Bare text with no `===` is treated as a single message to default_room. +fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Vec { let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } - if trimmed.starts_with("---") { - let parts: Vec<&str> = trimmed.splitn(3, "---").collect(); - if parts.len() >= 3 { - let frontmatter = parts[1].trim(); - let body = parts[2].trim(); + // Walk lines, splitting on lines that start with "=== " + let mut docs = Vec::new(); + let mut current_header: Option = None; + let mut current_body = String::new(); + let mut preamble = String::new(); - if frontmatter.contains("skip: true") || frontmatter.contains("skip:true") { - return None; - } - - // dm: takes precedence over room: if both set - let dm = frontmatter - .lines() - .find(|l| l.starts_with("dm:")) - .and_then(|l| l.strip_prefix("dm:")) - .and_then(|r| r.trim().parse::().ok()); - - let target = if let Some(user) = dm { - ResponseTarget::Dm(user) + for line in trimmed.lines() { + if let Some(header) = line.strip_prefix("===") { + // Flush previous doc + if let Some(h) = current_header.take() { + if let Some(doc) = build_doc(&h, current_body.trim(), default_room) { + docs.push(doc); + } + current_body.clear(); } else { - let room = frontmatter - .lines() - .find(|l| l.starts_with("room:")) - .and_then(|l| l.strip_prefix("room:")) - .and_then(|r| r.trim().parse().ok()) - .unwrap_or_else(|| default_room.clone()); - ResponseTarget::Room(room) - }; - - if body.is_empty() { - return None; + // We were collecting preamble + let p = preamble.trim(); + if !p.is_empty() { + docs.push(ClaudeDoc::Thought(p.to_owned())); + } + preamble.clear(); } + current_header = Some(header.trim().to_owned()); + } else if current_header.is_some() { + current_body.push_str(line); + current_body.push('\n'); + } else { + preamble.push_str(line); + preamble.push('\n'); + } + } - return Some(ClaudeResponse { - target, - body: body.to_owned(), + // Flush the last doc or preamble + if let Some(h) = current_header { + if let Some(doc) = build_doc(&h, current_body.trim(), default_room) { + docs.push(doc); + } + } else { + // No `===` headers at all - treat whole output as a single message + let p = preamble.trim(); + if !p.is_empty() { + docs.push(ClaudeDoc::Message { + target: ResponseTarget::Room(default_room.clone()), + body: p.to_owned(), }); } } - if trimmed.is_empty() { - return None; - } + docs +} - Some(ClaudeResponse { - target: ResponseTarget::Room(default_room.clone()), - body: trimmed.to_owned(), - }) +fn build_doc(header: &str, body: &str, default_room: &OwnedRoomId) -> Option { + let mut parts = header.splitn(2, char::is_whitespace); + let kind = parts.next().unwrap_or("").trim(); + let arg = parts.next().unwrap_or("").trim(); + + match kind { + "skip" => Some(ClaudeDoc::Skip), + "thought" => { + if body.is_empty() { + None + } else { + Some(ClaudeDoc::Thought(body.to_owned())) + } + } + "room" => { + if body.is_empty() { + return None; + } + let target = if arg.is_empty() { + ResponseTarget::Room(default_room.clone()) + } else { + match arg.parse::() { + Ok(rid) => ResponseTarget::Room(rid), + Err(_) => return None, + } + }; + Some(ClaudeDoc::Message { + target, + body: body.to_owned(), + }) + } + "dm" => { + if body.is_empty() { + return None; + } + match arg.parse::() { + Ok(uid) => Some(ClaudeDoc::Message { + target: ResponseTarget::Dm(uid), + body: body.to_owned(), + }), + Err(_) => None, + } + } + _ => { + // Unknown header - treat body as a thought so it doesn't leak to chat + if body.is_empty() { + None + } else { + Some(ClaudeDoc::Thought(format!( + "[unknown header '{header}'] {body}" + ))) + } + } + } } #[cfg(test)] @@ -887,66 +980,133 @@ mod tests { "!test:example.com".parse().unwrap() } - fn assert_room(resp: &ClaudeResponse, expected: &str) { - match &resp.target { + fn first_message(docs: &[ClaudeDoc]) -> (&ResponseTarget, &str) { + for d in docs { + if let ClaudeDoc::Message { target, body } = d { + return (target, body.as_str()); + } + } + panic!("no message doc found"); + } + + fn assert_room(target: &ResponseTarget, expected: &str) { + match target { ResponseTarget::Room(r) => assert_eq!(r.as_str(), expected), ResponseTarget::Dm(_) => panic!("expected room target, got dm"), } } #[test] - fn parse_frontmatter_response() { - let raw = "---\nroom: !other:server\n---\nhello world"; - let resp = parse_response(raw, &test_room()).unwrap(); - assert_room(&resp, "!other:server"); - assert_eq!(resp.body, "hello world"); + fn parse_room_with_arg() { + let raw = "=== room !other:server\nhello world"; + let docs = parse_response(raw, &test_room()); + let (target, body) = first_message(&docs); + assert_room(target, "!other:server"); + assert_eq!(body, "hello world"); } #[test] - fn parse_skip_response() { - let raw = "---\nskip: true\n---\n"; - assert!(parse_response(raw, &test_room()).is_none()); + fn parse_room_no_arg_uses_default() { + let raw = "=== room\nhi"; + let docs = parse_response(raw, &test_room()); + let (target, _) = first_message(&docs); + assert_room(target, "!test:example.com"); } #[test] - fn parse_plain_response() { + fn parse_skip() { + let raw = "=== skip"; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 1); + assert!(matches!(docs[0], ClaudeDoc::Skip)); + } + + #[test] + fn parse_plain_text_no_header() { let raw = "just a message"; - let resp = parse_response(raw, &test_room()).unwrap(); - assert_room(&resp, "!test:example.com"); - assert_eq!(resp.body, "just a message"); + let docs = parse_response(raw, &test_room()); + let (target, body) = first_message(&docs); + assert_room(target, "!test:example.com"); + assert_eq!(body, "just a message"); } #[test] - fn parse_empty_response() { - assert!(parse_response("", &test_room()).is_none()); - assert!(parse_response(" \n ", &test_room()).is_none()); + fn parse_empty() { + assert!(parse_response("", &test_room()).is_empty()); + assert!(parse_response(" \n ", &test_room()).is_empty()); } #[test] - fn parse_default_room() { - let raw = "---\n---\nhello"; - let resp = parse_response(raw, &test_room()).unwrap(); - assert_room(&resp, "!test:example.com"); - } - - #[test] - fn parse_dm_response() { - let raw = "---\ndm: @alice:example.com\n---\nhi alice"; - let resp = parse_response(raw, &test_room()).unwrap(); - match &resp.target { + fn parse_dm() { + let raw = "=== dm @alice:example.com\nhi alice"; + let docs = parse_response(raw, &test_room()); + let (target, body) = first_message(&docs); + match target { ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@alice:example.com"), ResponseTarget::Room(_) => panic!("expected dm target"), } - assert_eq!(resp.body, "hi alice"); + assert_eq!(body, "hi alice"); } #[test] - fn parse_dm_takes_precedence_over_room() { - let raw = "---\nroom: !other:server\ndm: @bob:example.com\n---\nhello"; - let resp = parse_response(raw, &test_room()).unwrap(); - match &resp.target { - ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@bob:example.com"), - ResponseTarget::Room(_) => panic!("expected dm target"), + fn parse_thought() { + let raw = "=== thought\nthinking about whether to reply..."; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 1); + match &docs[0] { + ClaudeDoc::Thought(s) => assert_eq!(s, "thinking about whether to reply..."), + _ => panic!("expected thought"), } } + + #[test] + fn parse_multi_doc() { + let raw = "\ +=== thought +let me check notes + +=== room !x:y +hi + +=== dm @u:s +private + +=== skip +"; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 4); + assert!(matches!(docs[0], ClaudeDoc::Thought(_))); + assert!(matches!( + docs[1], + ClaudeDoc::Message { + target: ResponseTarget::Room(_), + .. + } + )); + assert!(matches!( + docs[2], + ClaudeDoc::Message { + target: ResponseTarget::Dm(_), + .. + } + )); + assert!(matches!(docs[3], ClaudeDoc::Skip)); + } + + #[test] + fn parse_preamble_becomes_thought() { + let raw = "preamble line\n=== room !x:y\nhello"; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 2); + assert!(matches!(docs[0], ClaudeDoc::Thought(_))); + assert!(matches!(docs[1], ClaudeDoc::Message { .. })); + } + + #[test] + fn parse_unknown_header_becomes_thought() { + let raw = "=== mystery foo\nbody"; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 1); + assert!(matches!(docs[0], ClaudeDoc::Thought(_))); + } }