switch to '=== type' single-line headers for multi-doc output (more robust than ambiguous --- frontmatter)

This commit is contained in:
Damocles 2026-04-30 21:54:39 +02:00
parent aa4ed13518
commit d6d352d2f7

View file

@ -548,59 +548,72 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, 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::<String>(), "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<Option<ClaudeResponse>> {
) -> anyhow::Result<Vec<ClaudeDoc>> {
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<ClaudeResponse> {
/// Parse Claude's stdout into a list of documents.
///
/// Format: each doc starts with a line `=== <type> [arg]`. Body is everything
/// until the next `===` line or EOF. Types:
/// - `=== thought` → ClaudeDoc::Thought (logged, not sent)
/// - `=== room [<room_id>]` → ClaudeDoc::Message to that room (or source room if no arg)
/// - `=== dm <user_id>` → 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<ClaudeDoc> {
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<String> = 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::<OwnedUserId>().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<ClaudeDoc> {
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::<OwnedRoomId>() {
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::<OwnedUserId>() {
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(_)));
}
}