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

@@ -59,6 +59,34 @@ impl Indexer {
}
}
pub async fn add_reaction(&self, target_event_id: &str, sender: &str, emoji: &str, timestamp: i64) {
// Use a script to append to the reactions array (upsert-safe)
let body = json!({
"script": {
"source": "if (ctx._source.reactions == null) { ctx._source.reactions = []; } ctx._source.reactions.add(params.reaction)",
"params": {
"reaction": {
"sender": sender,
"emoji": emoji,
"timestamp": timestamp
}
}
}
});
if let Err(e) = self
.client
.update(opensearch::UpdateParts::IndexId(
&self.config.opensearch.index,
target_event_id,
))
.body(body)
.send()
.await
{
warn!(target_event_id, sender, emoji, "Failed to add reaction: {e}");
}
}
pub async fn update_redaction(&self, event_id: &str) {
let body = json!({
"doc": {

View File

@@ -24,6 +24,15 @@ pub struct ArchiveDocument {
pub edited: bool,
#[serde(default)]
pub redacted: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reactions: Vec<Reaction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reaction {
pub sender: String,
pub emoji: String,
pub timestamp: i64,
}
const INDEX_MAPPING: &str = r#"{
@@ -45,7 +54,15 @@ const INDEX_MAPPING: &str = r#"{
"media_urls": { "type": "keyword" },
"event_type": { "type": "keyword" },
"edited": { "type": "boolean" },
"redacted": { "type": "boolean" }
"redacted": { "type": "boolean" },
"reactions": {
"type": "nested",
"properties": {
"sender": { "type": "keyword" },
"emoji": { "type": "keyword" },
"timestamp": { "type": "date", "format": "epoch_millis" }
}
}
}
}
}"#;
@@ -63,6 +80,25 @@ pub async fn create_index_if_not_exists(client: &OpenSearch, index: &str) -> any
if exists.status_code().is_success() {
info!(index, "OpenSearch index already exists");
// Ensure reactions field exists (added after initial schema)
let reactions_mapping = serde_json::json!({
"properties": {
"reactions": {
"type": "nested",
"properties": {
"sender": { "type": "keyword" },
"emoji": { "type": "keyword" },
"timestamp": { "type": "date", "format": "epoch_millis" }
}
}
}
});
let _ = client
.indices()
.put_mapping(opensearch::indices::IndicesPutMappingParts::Index(&[index]))
.body(reactions_mapping)
.send()
.await;
return Ok(());
}
@@ -102,6 +138,7 @@ mod tests {
event_type: "m.room.message".to_string(),
edited: false,
redacted: false,
reactions: vec![],
}
}