* 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>
132 lines
4 KiB
Rust
132 lines
4 KiB
Rust
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// A single memory entry
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MemoryEntry {
|
|
pub id: String,
|
|
pub key: String,
|
|
pub content: String,
|
|
pub category: MemoryCategory,
|
|
pub timestamp: String,
|
|
pub session_id: Option<String>,
|
|
pub score: Option<f64>,
|
|
}
|
|
|
|
/// Memory categories for organization
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum MemoryCategory {
|
|
/// Long-term facts, preferences, decisions
|
|
Core,
|
|
/// Daily session logs
|
|
Daily,
|
|
/// Conversation context
|
|
Conversation,
|
|
/// User-defined custom category
|
|
Custom(String),
|
|
}
|
|
|
|
impl std::fmt::Display for MemoryCategory {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Core => write!(f, "core"),
|
|
Self::Daily => write!(f, "daily"),
|
|
Self::Conversation => write!(f, "conversation"),
|
|
Self::Custom(name) => write!(f, "{name}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Core memory trait — implement for any persistence backend
|
|
#[async_trait]
|
|
pub trait Memory: Send + Sync {
|
|
/// Backend name
|
|
fn name(&self) -> &str;
|
|
|
|
/// Store a memory entry, optionally scoped to a session
|
|
async fn store(
|
|
&self,
|
|
key: &str,
|
|
content: &str,
|
|
category: MemoryCategory,
|
|
session_id: Option<&str>,
|
|
) -> anyhow::Result<()>;
|
|
|
|
/// Recall memories matching a query (keyword search), optionally scoped to a session
|
|
async fn recall(
|
|
&self,
|
|
query: &str,
|
|
limit: usize,
|
|
session_id: Option<&str>,
|
|
) -> anyhow::Result<Vec<MemoryEntry>>;
|
|
|
|
/// Get a specific memory by key
|
|
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
|
|
|
|
/// List all memory keys, optionally filtered by category and/or session
|
|
async fn list(
|
|
&self,
|
|
category: Option<&MemoryCategory>,
|
|
session_id: Option<&str>,
|
|
) -> anyhow::Result<Vec<MemoryEntry>>;
|
|
|
|
/// Remove a memory by key
|
|
async fn forget(&self, key: &str) -> anyhow::Result<bool>;
|
|
|
|
/// Count total memories
|
|
async fn count(&self) -> anyhow::Result<usize>;
|
|
|
|
/// Health check
|
|
async fn health_check(&self) -> bool;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn memory_category_display_outputs_expected_values() {
|
|
assert_eq!(MemoryCategory::Core.to_string(), "core");
|
|
assert_eq!(MemoryCategory::Daily.to_string(), "daily");
|
|
assert_eq!(MemoryCategory::Conversation.to_string(), "conversation");
|
|
assert_eq!(
|
|
MemoryCategory::Custom("project_notes".into()).to_string(),
|
|
"project_notes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_category_serde_uses_snake_case() {
|
|
let core = serde_json::to_string(&MemoryCategory::Core).unwrap();
|
|
let daily = serde_json::to_string(&MemoryCategory::Daily).unwrap();
|
|
let conversation = serde_json::to_string(&MemoryCategory::Conversation).unwrap();
|
|
|
|
assert_eq!(core, "\"core\"");
|
|
assert_eq!(daily, "\"daily\"");
|
|
assert_eq!(conversation, "\"conversation\"");
|
|
}
|
|
|
|
#[test]
|
|
fn memory_entry_roundtrip_preserves_optional_fields() {
|
|
let entry = MemoryEntry {
|
|
id: "id-1".into(),
|
|
key: "favorite_language".into(),
|
|
content: "Rust".into(),
|
|
category: MemoryCategory::Core,
|
|
timestamp: "2026-02-16T00:00:00Z".into(),
|
|
session_id: Some("session-abc".into()),
|
|
score: Some(0.98),
|
|
};
|
|
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
let parsed: MemoryEntry = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(parsed.id, "id-1");
|
|
assert_eq!(parsed.key, "favorite_language");
|
|
assert_eq!(parsed.content, "Rust");
|
|
assert_eq!(parsed.category, MemoryCategory::Core);
|
|
assert_eq!(parsed.session_id.as_deref(), Some("session-abc"));
|
|
assert_eq!(parsed.score, Some(0.98));
|
|
}
|
|
}
|