zeroclaw/src/memory/markdown.rs
fettpl ebb78afda4
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>
2026-02-17 07:44:05 -05:00

356 lines
11 KiB
Rust

use super::traits::{Memory, MemoryCategory, MemoryEntry};
use async_trait::async_trait;
use chrono::Local;
use std::path::{Path, PathBuf};
use tokio::fs;
/// Markdown-based memory — plain files as source of truth
///
/// Layout:
/// workspace/MEMORY.md — curated long-term memory (core)
/// workspace/memory/YYYY-MM-DD.md — daily logs (append-only)
pub struct MarkdownMemory {
workspace_dir: PathBuf,
}
impl MarkdownMemory {
pub fn new(workspace_dir: &Path) -> Self {
Self {
workspace_dir: workspace_dir.to_path_buf(),
}
}
fn memory_dir(&self) -> PathBuf {
self.workspace_dir.join("memory")
}
fn core_path(&self) -> PathBuf {
self.workspace_dir.join("MEMORY.md")
}
fn daily_path(&self) -> PathBuf {
let date = Local::now().format("%Y-%m-%d").to_string();
self.memory_dir().join(format!("{date}.md"))
}
async fn ensure_dirs(&self) -> anyhow::Result<()> {
fs::create_dir_all(self.memory_dir()).await?;
Ok(())
}
async fn append_to_file(&self, path: &Path, content: &str) -> anyhow::Result<()> {
self.ensure_dirs().await?;
let existing = if path.exists() {
fs::read_to_string(path).await.unwrap_or_default()
} else {
String::new()
};
let updated = if existing.is_empty() {
let header = if path == self.core_path() {
"# Long-Term Memory\n\n"
} else {
let date = Local::now().format("%Y-%m-%d").to_string();
&format!("# Daily Log — {date}\n\n")
};
format!("{header}{content}\n")
} else {
format!("{existing}\n{content}\n")
};
fs::write(path, updated).await?;
Ok(())
}
fn parse_entries_from_file(
path: &Path,
content: &str,
category: &MemoryCategory,
) -> Vec<MemoryEntry> {
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
content
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
})
.enumerate()
.map(|(i, line)| {
let trimmed = line.trim();
let clean = trimmed.strip_prefix("- ").unwrap_or(trimmed);
MemoryEntry {
id: format!("{filename}:{i}"),
key: format!("{filename}:{i}"),
content: clean.to_string(),
category: category.clone(),
timestamp: filename.to_string(),
session_id: None,
score: None,
}
})
.collect()
}
async fn read_all_entries(&self) -> anyhow::Result<Vec<MemoryEntry>> {
let mut entries = Vec::new();
// Read MEMORY.md (core)
let core_path = self.core_path();
if core_path.exists() {
let content = fs::read_to_string(&core_path).await?;
entries.extend(Self::parse_entries_from_file(
&core_path,
&content,
&MemoryCategory::Core,
));
}
// Read daily logs
let mem_dir = self.memory_dir();
if mem_dir.exists() {
let mut dir = fs::read_dir(&mem_dir).await?;
while let Some(entry) = dir.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
let content = fs::read_to_string(&path).await?;
entries.extend(Self::parse_entries_from_file(
&path,
&content,
&MemoryCategory::Daily,
));
}
}
}
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(entries)
}
}
#[async_trait]
impl Memory for MarkdownMemory {
fn name(&self) -> &str {
"markdown"
}
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
_session_id: Option<&str>,
) -> anyhow::Result<()> {
let entry = format!("- **{key}**: {content}");
let path = match category {
MemoryCategory::Core => self.core_path(),
_ => self.daily_path(),
};
self.append_to_file(&path, &entry).await
}
async fn recall(
&self,
query: &str,
limit: usize,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let all = self.read_all_entries().await?;
let query_lower = query.to_lowercase();
let keywords: Vec<&str> = query_lower.split_whitespace().collect();
let mut scored: Vec<MemoryEntry> = all
.into_iter()
.filter_map(|mut entry| {
let content_lower = entry.content.to_lowercase();
let matched = keywords
.iter()
.filter(|kw| content_lower.contains(**kw))
.count();
if matched > 0 {
#[allow(clippy::cast_precision_loss)]
let score = matched as f64 / keywords.len() as f64;
entry.score = Some(score);
Some(entry)
} else {
None
}
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(limit);
Ok(scored)
}
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
let all = self.read_all_entries().await?;
Ok(all
.into_iter()
.find(|e| e.key == key || e.content.contains(key)))
}
async fn list(
&self,
category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let all = self.read_all_entries().await?;
match category {
Some(cat) => Ok(all.into_iter().filter(|e| &e.category == cat).collect()),
None => Ok(all),
}
}
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
// Markdown memory is append-only by design (audit trail)
// Return false to indicate the entry wasn't removed
Ok(false)
}
async fn count(&self) -> anyhow::Result<usize> {
let all = self.read_all_entries().await?;
Ok(all.len())
}
async fn health_check(&self) -> bool {
self.workspace_dir.exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs as sync_fs;
use tempfile::TempDir;
fn temp_workspace() -> (TempDir, MarkdownMemory) {
let tmp = TempDir::new().unwrap();
let mem = MarkdownMemory::new(tmp.path());
(tmp, mem)
}
#[tokio::test]
async fn markdown_name() {
let (_tmp, mem) = temp_workspace();
assert_eq!(mem.name(), "markdown");
}
#[tokio::test]
async fn markdown_health_check() {
let (_tmp, mem) = temp_workspace();
assert!(mem.health_check().await);
}
#[tokio::test]
async fn markdown_store_core() {
let (_tmp, mem) = temp_workspace();
mem.store("pref", "User likes Rust", MemoryCategory::Core, None)
.await
.unwrap();
let content = sync_fs::read_to_string(mem.core_path()).unwrap();
assert!(content.contains("User likes Rust"));
}
#[tokio::test]
async fn markdown_store_daily() {
let (_tmp, mem) = temp_workspace();
mem.store("note", "Finished tests", MemoryCategory::Daily, None)
.await
.unwrap();
let path = mem.daily_path();
let content = sync_fs::read_to_string(path).unwrap();
assert!(content.contains("Finished tests"));
}
#[tokio::test]
async fn markdown_recall_keyword() {
let (_tmp, mem) = temp_workspace();
mem.store("a", "Rust is fast", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("b", "Python is slow", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("c", "Rust and safety", MemoryCategory::Core, None)
.await
.unwrap();
let results = mem.recall("Rust", 10, None).await.unwrap();
assert!(results.len() >= 2);
assert!(results
.iter()
.all(|r| r.content.to_lowercase().contains("rust")));
}
#[tokio::test]
async fn markdown_recall_no_match() {
let (_tmp, mem) = temp_workspace();
mem.store("a", "Rust is great", MemoryCategory::Core, None)
.await
.unwrap();
let results = mem.recall("javascript", 10, None).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn markdown_count() {
let (_tmp, mem) = temp_workspace();
mem.store("a", "first", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("b", "second", MemoryCategory::Core, None)
.await
.unwrap();
let count = mem.count().await.unwrap();
assert!(count >= 2);
}
#[tokio::test]
async fn markdown_list_by_category() {
let (_tmp, mem) = temp_workspace();
mem.store("a", "core fact", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("b", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
let core = mem.list(Some(&MemoryCategory::Core), None).await.unwrap();
assert!(core.iter().all(|e| e.category == MemoryCategory::Core));
let daily = mem.list(Some(&MemoryCategory::Daily), None).await.unwrap();
assert!(daily.iter().all(|e| e.category == MemoryCategory::Daily));
}
#[tokio::test]
async fn markdown_forget_is_noop() {
let (_tmp, mem) = temp_workspace();
mem.store("a", "permanent", MemoryCategory::Core, None)
.await
.unwrap();
let removed = mem.forget("a").await.unwrap();
assert!(!removed, "Markdown memory is append-only");
}
#[tokio::test]
async fn markdown_empty_recall() {
let (_tmp, mem) = temp_workspace();
let results = mem.recall("anything", 10, None).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn markdown_empty_count() {
let (_tmp, mem) = temp_workspace();
assert_eq!(mem.count().await.unwrap(), 0);
}
}