switch to '=== type' single-line headers for multi-doc output (more robust than ambiguous --- frontmatter)
This commit is contained in:
parent
aa4ed13518
commit
d6d352d2f7
1 changed files with 288 additions and 128 deletions
344
src/main.rs
344
src/main.rs
|
|
@ -548,9 +548,26 @@ 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 {
|
||||
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),
|
||||
|
|
@ -560,26 +577,22 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
|
|||
}
|
||||
},
|
||||
};
|
||||
let target_label = match &response.target {
|
||||
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(&response.body);
|
||||
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);
|
||||
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;
|
||||
sent_any = true;
|
||||
}
|
||||
Err(e) => tracing::error!("failed to send: {e}"),
|
||||
}
|
||||
|
|
@ -587,20 +600,20 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
|
|||
tracing::warn!(target = %target_label, "target not available");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(room = %room_id, "claude chose to skip");
|
||||
}
|
||||
}
|
||||
|
||||
// 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.clone()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(room = %room_id, "claude invocation failed: {e}");
|
||||
}
|
||||
}
|
||||
send_read_receipt(&room, new_last_event_id).await;
|
||||
|
||||
let _ = sent_any;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -720,9 +733,18 @@ enum ResponseTarget {
|
|||
Dm(OwnedUserId),
|
||||
}
|
||||
|
||||
struct ClaudeResponse {
|
||||
/// 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.starts_with("---") {
|
||||
let parts: Vec<&str> = trimmed.splitn(3, "---").collect();
|
||||
if parts.len() >= 3 {
|
||||
let frontmatter = parts[1].trim();
|
||||
let body = parts[2].trim();
|
||||
|
||||
if frontmatter.contains("skip: true") || frontmatter.contains("skip:true") {
|
||||
return None;
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// 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());
|
||||
// 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();
|
||||
|
||||
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() {
|
||||
docs
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Some(ClaudeResponse {
|
||||
target: ResponseTarget::Room(default_room.clone()),
|
||||
body: trimmed.to_owned(),
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue