evaluator redesign: response types, silence, structural suppression
new engagement types: Respond (inline), ThreadReply (threaded), React, Ignore. LLM returns response_type to decide HOW to engage. silence mechanic: "shut up"/"be quiet" sets a 30min per-room timer. only direct @mention breaks through. structural suppression (A+B): - reply to non-Sol human → capped at React - 3+ human messages since Sol → forced passive mode threads have a lower relevance threshold (70% of spontaneous). time context injected into evaluator prompt.
This commit is contained in:
99
src/sync.rs
99
src/sync.rs
@@ -42,6 +42,8 @@ pub struct AppState {
|
||||
pub last_response: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
/// Tracks rooms where a response is currently being generated (in-flight guard)
|
||||
pub responding_in: Arc<Mutex<std::collections::HashSet<String>>>,
|
||||
/// Rooms where Sol has been told to be quiet — maps room_id → silenced_until
|
||||
pub silenced_until: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
}
|
||||
|
||||
pub async fn start_sync(client: Client, state: Arc<AppState>) -> anyhow::Result<()> {
|
||||
@@ -193,6 +195,38 @@ async fn handle_message(
|
||||
);
|
||||
}
|
||||
|
||||
// Silence detection — if someone tells Sol to be quiet, set a per-room timer
|
||||
{
|
||||
let lower = body.to_lowercase();
|
||||
let silence_phrases = [
|
||||
"shut up", "be quiet", "shush", "silence", "stop talking",
|
||||
"quiet down", "hush", "enough sol", "sol enough", "sol stop",
|
||||
"sol shut up", "sol be quiet", "sol shush",
|
||||
];
|
||||
if silence_phrases.iter().any(|p| lower.contains(p)) {
|
||||
let duration = std::time::Duration::from_millis(
|
||||
state.config.behavior.silence_duration_ms,
|
||||
);
|
||||
let until = Instant::now() + duration;
|
||||
let mut silenced = state.silenced_until.lock().await;
|
||||
silenced.insert(room_id.clone(), until);
|
||||
info!(
|
||||
room = room_id.as_str(),
|
||||
duration_mins = state.config.behavior.silence_duration_ms / 60_000,
|
||||
"Silenced in room"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Sol is currently silenced in this room
|
||||
let is_silenced = {
|
||||
let silenced = state.silenced_until.lock().await;
|
||||
silenced
|
||||
.get(&room_id)
|
||||
.map(|until| Instant::now() < *until)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// Evaluate whether to respond
|
||||
let recent: Vec<String> = {
|
||||
let convs = state.conversations.lock().await;
|
||||
@@ -203,28 +237,65 @@ async fn handle_message(
|
||||
.collect()
|
||||
};
|
||||
|
||||
// A: Check if this message is a reply to another human (not Sol)
|
||||
let is_reply_to_human = is_reply && !is_dm && {
|
||||
// If it's a reply, check the conversation context for who the previous
|
||||
// message was from. We don't have event IDs in context, so we use a
|
||||
// heuristic: if the most recent message before this one was from a human
|
||||
// (not Sol), this reply is likely directed at them.
|
||||
let convs = state.conversations.lock().await;
|
||||
let ctx = convs.get_context(&room_id);
|
||||
let sol_id = &state.config.matrix.user_id;
|
||||
// Check the message before the current one (last in context before we added ours)
|
||||
ctx.iter().rev().skip(1).next()
|
||||
.map(|m| m.sender != *sol_id)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// B: Count messages since Sol last spoke in this room
|
||||
let messages_since_sol = {
|
||||
let convs = state.conversations.lock().await;
|
||||
let ctx = convs.get_context(&room_id);
|
||||
let sol_id = &state.config.matrix.user_id;
|
||||
ctx.iter().rev().take_while(|m| m.sender != *sol_id).count()
|
||||
};
|
||||
|
||||
let engagement = state
|
||||
.evaluator
|
||||
.evaluate(&sender, &body, is_dm, &recent, &state.mistral)
|
||||
.evaluate(
|
||||
&sender, &body, is_dm, &recent, &state.mistral,
|
||||
is_reply_to_human, messages_since_sol, is_silenced,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (should_respond, is_spontaneous) = match engagement {
|
||||
// use_thread: if true, Sol responds in a thread instead of inline
|
||||
let (should_respond, is_spontaneous, use_thread) = match engagement {
|
||||
Engagement::MustRespond { reason } => {
|
||||
info!(room = room_id.as_str(), ?reason, "Must respond");
|
||||
(true, false)
|
||||
// Direct mention breaks silence
|
||||
if is_silenced {
|
||||
let mut silenced = state.silenced_until.lock().await;
|
||||
silenced.remove(&room_id);
|
||||
info!(room = room_id.as_str(), "Silence broken by direct mention");
|
||||
}
|
||||
(true, false, false)
|
||||
}
|
||||
Engagement::MaybeRespond { relevance, hook } => {
|
||||
info!(room = room_id.as_str(), relevance, hook = hook.as_str(), "Maybe respond (spontaneous)");
|
||||
(true, true)
|
||||
Engagement::Respond { relevance, hook } => {
|
||||
info!(room = room_id.as_str(), relevance, hook = hook.as_str(), "Respond (spontaneous)");
|
||||
(true, true, false)
|
||||
}
|
||||
Engagement::ThreadReply { relevance, hook } => {
|
||||
info!(room = room_id.as_str(), relevance, hook = hook.as_str(), "Thread reply (spontaneous)");
|
||||
(true, true, true)
|
||||
}
|
||||
Engagement::React { emoji, relevance } => {
|
||||
info!(room = room_id.as_str(), relevance, emoji = emoji.as_str(), "Reacting with emoji");
|
||||
if let Err(e) = matrix_utils::send_reaction(&room, event.event_id.clone().into(), &emoji).await {
|
||||
error!("Failed to send reaction: {e}");
|
||||
}
|
||||
(false, false)
|
||||
(false, false, false)
|
||||
}
|
||||
Engagement::Ignore => (false, false),
|
||||
Engagement::Ignore => (false, false, false),
|
||||
};
|
||||
|
||||
if !should_respond {
|
||||
@@ -310,6 +381,7 @@ async fn handle_message(
|
||||
&state.conversation_registry,
|
||||
image_data_uri.as_deref(),
|
||||
context_hint,
|
||||
event.event_id.clone().into(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
@@ -331,17 +403,20 @@ async fn handle_message(
|
||||
};
|
||||
|
||||
if let Some(text) = response {
|
||||
// Reply with reference only when directly addressed. Spontaneous
|
||||
// and DM messages are sent as plain content — feels more natural.
|
||||
let content = if !is_spontaneous && !is_dm {
|
||||
let content = if use_thread {
|
||||
// Thread reply — less intrusive, for tangential contributions
|
||||
matrix_utils::make_thread_reply(&text, event.event_id.to_owned())
|
||||
} else if !is_spontaneous && !is_dm {
|
||||
// Direct reply — when explicitly addressed
|
||||
matrix_utils::make_reply_content(&text, event.event_id.to_owned())
|
||||
} else {
|
||||
// Plain message — spontaneous or DM, feels more natural
|
||||
ruma::events::room::message::RoomMessageEventContent::text_markdown(&text)
|
||||
};
|
||||
if let Err(e) = room.send(content).await {
|
||||
error!("Failed to send response: {e}");
|
||||
} else {
|
||||
info!(room = room_id.as_str(), len = text.len(), is_dm, "Response sent");
|
||||
info!(room = room_id.as_str(), len = text.len(), is_dm, use_thread, "Response sent");
|
||||
}
|
||||
// Post-response memory extraction (fire-and-forget)
|
||||
if state.config.behavior.memory_extraction_enabled {
|
||||
|
||||
Reference in New Issue
Block a user