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,5 +1,6 @@
pub mod room_history;
pub mod room_info;
pub mod script;
pub mod search;
use std::sync::Arc;
@@ -10,6 +11,7 @@ use opensearch::OpenSearch;
use serde_json::json;
use crate::config::Config;
use crate::context::ResponseContext;
pub struct ToolRegistry {
opensearch: OpenSearch,
@@ -122,10 +124,44 @@ impl ToolRegistry {
"required": ["room_id"]
}),
),
Tool::new(
"run_script".into(),
"Execute a TypeScript/JavaScript snippet in a sandboxed runtime. \
Use this for math, date calculations, data transformations, or any \
computation that needs precision. The script has access to:\n\
- sol.search(query, opts?) — search the message archive. opts: \
{ room?, sender?, after?, before?, limit?, semantic? }\n\
- sol.rooms() — list joined rooms (returns array of {name, id, members})\n\
- sol.members(roomName) — get room members (returns array of {name, id})\n\
- sol.fetch(url) — HTTP GET (allowlisted domains only)\n\
- sol.memory.get(query?) — retrieve internal notes relevant to the query\n\
- sol.memory.set(content, category?) — save an internal note for later reference\n\
- sol.fs.read(path), sol.fs.write(path, content), sol.fs.list(path?) — \
sandboxed temp filesystem for intermediate files\n\
- console.log() to produce output\n\
All sol.* methods are async — use await. The last expression value is \
also captured. Output is truncated to 4096 chars."
.into(),
json!({
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "TypeScript or JavaScript code to execute"
}
},
"required": ["code"]
}),
),
]
}
pub async fn execute(&self, name: &str, arguments: &str) -> anyhow::Result<String> {
pub async fn execute(
&self,
name: &str,
arguments: &str,
response_ctx: &ResponseContext,
) -> anyhow::Result<String> {
match name {
"search_archive" => {
search::search_archive(
@@ -145,6 +181,16 @@ impl ToolRegistry {
}
"list_rooms" => room_info::list_rooms(&self.matrix).await,
"get_room_members" => room_info::get_room_members(&self.matrix, arguments).await,
"run_script" => {
script::run_script(
&self.opensearch,
&self.matrix,
&self.config,
arguments,
response_ctx,
)
.await
}
_ => anyhow::bail!("Unknown tool: {name}"),
}
}