feat: per-user auto-memory with ResponseContext
Three memory channels: hidden tool (sol.memory.set/get in scripts), pre-response injection (relevant memories loaded into system prompt), and post-response extraction (ministral-3b extracts facts after each response). User isolation enforced at Rust level — user_id derived from Matrix sender, never from script arguments. New modules: context (ResponseContext), memory (schema, store, extractor). ResponseContext threaded through responder → tools → script runtime. OpenSearch index sol_user_memory created on startup alongside archive.
This commit is contained in:
141
src/sync.rs
141
src/sync.rs
@@ -1,13 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use matrix_sdk::config::SyncSettings;
|
||||
use matrix_sdk::room::Room;
|
||||
use matrix_sdk::Client;
|
||||
use ruma::events::reaction::OriginalSyncReactionEvent;
|
||||
use ruma::events::room::member::StrippedRoomMemberEvent;
|
||||
use ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
||||
use ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use opensearch::OpenSearch;
|
||||
|
||||
use crate::archive::indexer::Indexer;
|
||||
use crate::archive::schema::ArchiveDocument;
|
||||
@@ -15,7 +20,9 @@ use crate::brain::conversation::{ContextMessage, ConversationManager};
|
||||
use crate::brain::evaluator::{Engagement, Evaluator};
|
||||
use crate::brain::responder::Responder;
|
||||
use crate::config::Config;
|
||||
use crate::context::{self, ResponseContext};
|
||||
use crate::matrix_utils;
|
||||
use crate::memory;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
@@ -24,6 +31,11 @@ pub struct AppState {
|
||||
pub responder: Arc<Responder>,
|
||||
pub conversations: Arc<Mutex<ConversationManager>>,
|
||||
pub mistral: Arc<mistralai_client::v1::client::Client>,
|
||||
pub opensearch: OpenSearch,
|
||||
/// Tracks when Sol last responded in each room (for cooldown)
|
||||
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>>>,
|
||||
}
|
||||
|
||||
pub async fn start_sync(client: Client, state: Arc<AppState>) -> anyhow::Result<()> {
|
||||
@@ -50,6 +62,16 @@ pub async fn start_sync(client: Client, state: Arc<AppState>) -> anyhow::Result<
|
||||
},
|
||||
);
|
||||
|
||||
let s = state.clone();
|
||||
client.add_event_handler(
|
||||
move |event: OriginalSyncReactionEvent, _room: Room| {
|
||||
let state = s.clone();
|
||||
async move {
|
||||
handle_reaction(event, &state).await;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
client.add_event_handler(
|
||||
move |event: StrippedRoomMemberEvent, room: Room| async move {
|
||||
handle_invite(event, room).await;
|
||||
@@ -95,6 +117,7 @@ async fn handle_message(
|
||||
.and_then(|m| m.display_name().map(|s| s.to_string()));
|
||||
|
||||
let reply_to = matrix_utils::extract_reply_to(&event).map(|id| id.to_string());
|
||||
let is_reply = reply_to.is_some();
|
||||
let thread_id = matrix_utils::extract_thread_id(&event).map(|id| id.to_string());
|
||||
|
||||
// Archive the message
|
||||
@@ -112,11 +135,22 @@ async fn handle_message(
|
||||
event_type: "m.room.message".into(),
|
||||
edited: false,
|
||||
redacted: false,
|
||||
reactions: Vec::new(),
|
||||
};
|
||||
state.indexer.add(doc).await;
|
||||
|
||||
// Update conversation context
|
||||
let is_dm = room.is_direct().await.unwrap_or(false);
|
||||
|
||||
let response_ctx = ResponseContext {
|
||||
matrix_user_id: sender.clone(),
|
||||
user_id: context::derive_user_id(&sender),
|
||||
display_name: sender_name.clone(),
|
||||
is_dm,
|
||||
is_reply,
|
||||
room_id: room_id.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut convs = state.conversations.lock().await;
|
||||
convs.add_message(
|
||||
@@ -147,13 +181,20 @@ async fn handle_message(
|
||||
|
||||
let (should_respond, is_spontaneous) = match engagement {
|
||||
Engagement::MustRespond { reason } => {
|
||||
info!(?reason, "Must respond");
|
||||
info!(room = room_id.as_str(), ?reason, "Must respond");
|
||||
(true, false)
|
||||
}
|
||||
Engagement::MaybeRespond { relevance, hook } => {
|
||||
info!(relevance, hook = hook.as_str(), "Maybe respond (spontaneous)");
|
||||
info!(room = room_id.as_str(), relevance, hook = hook.as_str(), "Maybe respond (spontaneous)");
|
||||
(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)
|
||||
}
|
||||
Engagement::Ignore => (false, false),
|
||||
};
|
||||
|
||||
@@ -161,8 +202,38 @@ async fn handle_message(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Show typing indicator
|
||||
let _ = room.typing_notice(true).await;
|
||||
// In-flight guard: skip if we're already generating a response for this room
|
||||
{
|
||||
let responding = state.responding_in.lock().await;
|
||||
if responding.contains(&room_id) {
|
||||
debug!(room = room_id.as_str(), "Skipping — response already in flight for this room");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown check: skip spontaneous if we responded recently
|
||||
if is_spontaneous {
|
||||
let last = state.last_response.lock().await;
|
||||
if let Some(ts) = last.get(&room_id) {
|
||||
let elapsed = ts.elapsed().as_millis() as u64;
|
||||
let cooldown = state.config.behavior.cooldown_after_response_ms;
|
||||
if elapsed < cooldown {
|
||||
debug!(
|
||||
room = room_id.as_str(),
|
||||
elapsed_ms = elapsed,
|
||||
cooldown_ms = cooldown,
|
||||
"Skipping spontaneous — within cooldown period"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark room as in-flight
|
||||
{
|
||||
let mut responding = state.responding_in.lock().await;
|
||||
responding.insert(room_id.clone());
|
||||
}
|
||||
|
||||
let context = {
|
||||
let convs = state.conversations.lock().await;
|
||||
@@ -181,22 +252,74 @@ async fn handle_message(
|
||||
&members,
|
||||
is_spontaneous,
|
||||
&state.mistral,
|
||||
&room,
|
||||
&response_ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Stop typing indicator
|
||||
let _ = room.typing_notice(false).await;
|
||||
|
||||
if let Some(text) = response {
|
||||
let content = matrix_utils::make_reply_content(&text, event.event_id.to_owned());
|
||||
// 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 {
|
||||
matrix_utils::make_reply_content(&text, event.event_id.to_owned())
|
||||
} else {
|
||||
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");
|
||||
}
|
||||
// Post-response memory extraction (fire-and-forget)
|
||||
if state.config.behavior.memory_extraction_enabled {
|
||||
let ctx = response_ctx.clone();
|
||||
let mistral = state.mistral.clone();
|
||||
let os = state.opensearch.clone();
|
||||
let config = state.config.clone();
|
||||
let user_msg = body.clone();
|
||||
let sol_response = text.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = memory::extractor::extract_and_store(
|
||||
&mistral, &os, &config, &ctx, &user_msg, &sol_response,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Memory extraction failed (non-fatal): {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update last response timestamp
|
||||
let mut last = state.last_response.lock().await;
|
||||
last.insert(room_id.clone(), Instant::now());
|
||||
}
|
||||
|
||||
// Clear in-flight flag
|
||||
{
|
||||
let mut responding = state.responding_in.lock().await;
|
||||
responding.remove(&room_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_reaction(event: OriginalSyncReactionEvent, state: &AppState) {
|
||||
let target_event_id = event.content.relates_to.event_id.to_string();
|
||||
let sender = event.sender.to_string();
|
||||
let emoji = &event.content.relates_to.key;
|
||||
let timestamp: i64 = event.origin_server_ts.0.into();
|
||||
|
||||
info!(
|
||||
target = target_event_id.as_str(),
|
||||
sender = sender.as_str(),
|
||||
emoji = emoji.as_str(),
|
||||
"Indexing reaction"
|
||||
);
|
||||
|
||||
state.indexer.add_reaction(&target_event_id, &sender, emoji, timestamp).await;
|
||||
}
|
||||
|
||||
async fn handle_redaction(event: OriginalSyncRoomRedactionEvent, state: &AppState) {
|
||||
if let Some(redacted_id) = &event.redacts {
|
||||
state.indexer.update_redaction(&redacted_id.to_string()).await;
|
||||
|
||||
Reference in New Issue
Block a user