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:
2026-03-21 15:51:31 +00:00
parent 4dc20bee23
commit 4949e70ecc
23 changed files with 4494 additions and 124 deletions

View File

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