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,7 +1,7 @@
use opensearch::OpenSearch;
use serde::Deserialize;
use serde_json::json;
use tracing::debug;
use tracing::{debug, info};
#[derive(Debug, Deserialize)]
pub struct SearchArgs {
@@ -24,19 +24,24 @@ fn default_limit() -> usize { 10 }
/// Build the OpenSearch query body from parsed SearchArgs. Extracted for testability.
pub fn build_search_query(args: &SearchArgs) -> serde_json::Value {
let must = vec![json!({
"match": { "content": args.query }
})];
// Handle empty/wildcard queries as match_all
let must = if args.query.is_empty() || args.query == "*" {
vec![json!({ "match_all": {} })]
} else {
vec![json!({
"match": { "content": args.query }
})]
};
let mut filter = vec![json!({
"term": { "redacted": false }
})];
if let Some(ref room) = args.room {
filter.push(json!({ "term": { "room_name": room } }));
filter.push(json!({ "term": { "room_name.keyword": room } }));
}
if let Some(ref sender) = args.sender {
filter.push(json!({ "term": { "sender_name": sender } }));
filter.push(json!({ "term": { "sender_name.keyword": sender } }));
}
let mut range = serde_json::Map::new();
@@ -73,10 +78,19 @@ pub async fn search_archive(
args_json: &str,
) -> anyhow::Result<String> {
let args: SearchArgs = serde_json::from_str(args_json)?;
debug!(query = args.query.as_str(), "Searching archive");
let query_body = build_search_query(&args);
info!(
query = args.query.as_str(),
room = args.room.as_deref().unwrap_or("*"),
sender = args.sender.as_deref().unwrap_or("*"),
after = args.after.as_deref().unwrap_or("*"),
before = args.before.as_deref().unwrap_or("*"),
limit = args.limit,
query_json = %query_body,
"Executing search"
);
let response = client
.search(opensearch::SearchParts::Index(&[index]))
.body(query_body)
@@ -84,6 +98,8 @@ pub async fn search_archive(
.await?;
let body: serde_json::Value = response.json().await?;
let hit_count = body["hits"]["total"]["value"].as_i64().unwrap_or(0);
info!(hit_count, "Search results");
let hits = &body["hits"]["hits"];
let Some(hits_arr) = hits.as_array() else {
@@ -173,7 +189,7 @@ mod tests {
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
assert_eq!(filters.len(), 2);
assert_eq!(filters[1]["term"]["room_name"], "design");
assert_eq!(filters[1]["term"]["room_name.keyword"], "design");
}
#[test]
@@ -183,7 +199,7 @@ mod tests {
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
assert_eq!(filters.len(), 2);
assert_eq!(filters[1]["term"]["sender_name"], "Bob");
assert_eq!(filters[1]["term"]["sender_name.keyword"], "Bob");
}
#[test]
@@ -193,8 +209,8 @@ mod tests {
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
assert_eq!(filters.len(), 3);
assert_eq!(filters[1]["term"]["room_name"], "dev");
assert_eq!(filters[2]["term"]["sender_name"], "Carol");
assert_eq!(filters[1]["term"]["room_name.keyword"], "dev");
assert_eq!(filters[2]["term"]["sender_name.keyword"], "Carol");
}
#[test]