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:
2026-03-23 01:41:57 +00:00
parent 1058afb635
commit 3b62d86c45
3 changed files with 244 additions and 48 deletions

View File

@@ -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 {