feat(memory): add session_id isolation to Memory trait (#530)

* feat(memory): add session_id isolation to Memory trait

Add optional session_id parameter to store(), recall(), and list()
methods across the Memory trait and all four backends (sqlite, markdown,
lucid, none). This enables per-session memory isolation so different
agent sessions cannot cross-read each other's stored memories.

Changes:
- traits.rs: Add session_id: Option<&str> to store/recall/list
- sqlite.rs: Schema migration (ALTER TABLE ADD COLUMN session_id),
  index, persist/filter by session_id in all query paths
- markdown.rs, lucid.rs, none.rs: Updated signatures
- All callers pass None for backward compatibility
- 5 new tests: session-filtered recall, cross-session isolation,
  session-filtered list, no-filter returns all, migration idempotency

Closes #518

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channels): fix discord _channel_id typo and lark missing reply_to

Pre-existing compilation errors on main after reply_to was added to
ChannelMessage: discord.rs used _channel_id (underscore prefix) but
referenced channel_id, and lark.rs was missing the reply_to field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fettpl 2026-02-17 13:44:05 +01:00 committed by GitHub
parent f30f87662e
commit ebb78afda4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 556 additions and 221 deletions

View file

@ -36,6 +36,7 @@ async fn compare_store_speed() {
&format!("key_{i}"),
&format!("Memory entry number {i} about Rust programming"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
@ -49,6 +50,7 @@ async fn compare_store_speed() {
&format!("key_{i}"),
&format!("Memory entry number {i} about Rust programming"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
@ -127,8 +129,8 @@ async fn compare_recall_quality() {
];
for (key, content, cat) in &entries {
sq.store(key, content, cat.clone()).await.unwrap();
md.store(key, content, cat.clone()).await.unwrap();
sq.store(key, content, cat.clone(), None).await.unwrap();
md.store(key, content, cat.clone(), None).await.unwrap();
}
// Test queries and compare results
@ -145,8 +147,8 @@ async fn compare_recall_quality() {
println!("RECALL QUALITY (10 entries seeded):\n");
for (query, desc) in &queries {
let sq_results = sq.recall(query, 10).await.unwrap();
let md_results = md.recall(query, 10).await.unwrap();
let sq_results = sq.recall(query, 10, None).await.unwrap();
let md_results = md.recall(query, 10, None).await.unwrap();
println!(" Query: \"{query}\"{desc}");
println!(" SQLite: {} results", sq_results.len());
@ -190,21 +192,21 @@ async fn compare_recall_speed() {
} else {
format!("TypeScript powers modern web apps, entry {i}")
};
sq.store(&format!("e{i}"), &content, MemoryCategory::Core)
sq.store(&format!("e{i}"), &content, MemoryCategory::Core, None)
.await
.unwrap();
md.store(&format!("e{i}"), &content, MemoryCategory::Daily)
md.store(&format!("e{i}"), &content, MemoryCategory::Daily, None)
.await
.unwrap();
}
// Benchmark recall
let start = Instant::now();
let sq_results = sq.recall("Rust systems", 10).await.unwrap();
let sq_results = sq.recall("Rust systems", 10, None).await.unwrap();
let sq_dur = start.elapsed();
let start = Instant::now();
let md_results = md.recall("Rust systems", 10).await.unwrap();
let md_results = md.recall("Rust systems", 10, None).await.unwrap();
let md_dur = start.elapsed();
println!("\n============================================================");
@ -227,15 +229,25 @@ async fn compare_persistence() {
// Store in both, then drop and re-open
{
let sq = sqlite_backend(tmp_sq.path());
sq.store("persist_test", "I should survive", MemoryCategory::Core)
.await
.unwrap();
sq.store(
"persist_test",
"I should survive",
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
{
let md = markdown_backend(tmp_md.path());
md.store("persist_test", "I should survive", MemoryCategory::Core)
.await
.unwrap();
md.store(
"persist_test",
"I should survive",
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
// Re-open
@ -282,17 +294,17 @@ async fn compare_upsert() {
let md = markdown_backend(tmp_md.path());
// Store twice with same key, different content
sq.store("pref", "likes Rust", MemoryCategory::Core)
sq.store("pref", "likes Rust", MemoryCategory::Core, None)
.await
.unwrap();
sq.store("pref", "loves Rust", MemoryCategory::Core)
sq.store("pref", "loves Rust", MemoryCategory::Core, None)
.await
.unwrap();
md.store("pref", "likes Rust", MemoryCategory::Core)
md.store("pref", "likes Rust", MemoryCategory::Core, None)
.await
.unwrap();
md.store("pref", "loves Rust", MemoryCategory::Core)
md.store("pref", "loves Rust", MemoryCategory::Core, None)
.await
.unwrap();
@ -300,7 +312,7 @@ async fn compare_upsert() {
let md_count = md.count().await.unwrap();
let sq_entry = sq.get("pref").await.unwrap();
let md_results = md.recall("loves Rust", 5).await.unwrap();
let md_results = md.recall("loves Rust", 5, None).await.unwrap();
println!("\n============================================================");
println!("UPSERT (store same key twice):");
@ -328,10 +340,10 @@ async fn compare_forget() {
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
sq.store("secret", "API key: sk-1234", MemoryCategory::Core)
sq.store("secret", "API key: sk-1234", MemoryCategory::Core, None)
.await
.unwrap();
md.store("secret", "API key: sk-1234", MemoryCategory::Core)
md.store("secret", "API key: sk-1234", MemoryCategory::Core, None)
.await
.unwrap();
@ -372,37 +384,40 @@ async fn compare_category_filter() {
let md = markdown_backend(tmp_md.path());
// Mix of categories
sq.store("a", "core fact 1", MemoryCategory::Core)
sq.store("a", "core fact 1", MemoryCategory::Core, None)
.await
.unwrap();
sq.store("b", "core fact 2", MemoryCategory::Core)
sq.store("b", "core fact 2", MemoryCategory::Core, None)
.await
.unwrap();
sq.store("c", "daily note", MemoryCategory::Daily)
sq.store("c", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
sq.store("d", "convo msg", MemoryCategory::Conversation)
sq.store("d", "convo msg", MemoryCategory::Conversation, None)
.await
.unwrap();
md.store("a", "core fact 1", MemoryCategory::Core)
md.store("a", "core fact 1", MemoryCategory::Core, None)
.await
.unwrap();
md.store("b", "core fact 2", MemoryCategory::Core)
md.store("b", "core fact 2", MemoryCategory::Core, None)
.await
.unwrap();
md.store("c", "daily note", MemoryCategory::Daily)
md.store("c", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
let sq_core = sq.list(Some(&MemoryCategory::Core)).await.unwrap();
let sq_daily = sq.list(Some(&MemoryCategory::Daily)).await.unwrap();
let sq_conv = sq.list(Some(&MemoryCategory::Conversation)).await.unwrap();
let sq_all = sq.list(None).await.unwrap();
let sq_core = sq.list(Some(&MemoryCategory::Core), None).await.unwrap();
let sq_daily = sq.list(Some(&MemoryCategory::Daily), None).await.unwrap();
let sq_conv = sq
.list(Some(&MemoryCategory::Conversation), None)
.await
.unwrap();
let sq_all = sq.list(None, None).await.unwrap();
let md_core = md.list(Some(&MemoryCategory::Core)).await.unwrap();
let md_daily = md.list(Some(&MemoryCategory::Daily)).await.unwrap();
let md_all = md.list(None).await.unwrap();
let md_core = md.list(Some(&MemoryCategory::Core), None).await.unwrap();
let md_daily = md.list(Some(&MemoryCategory::Daily), None).await.unwrap();
let md_all = md.list(None, None).await.unwrap();
println!("\n============================================================");
println!("CATEGORY FILTERING:");