feat: initial release — ZeroClaw v0.1.0
- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings
This commit is contained in:
commit
05cb353f7f
71 changed files with 15757 additions and 0 deletions
344
src/memory/markdown.rs
Normal file
344
src/memory/markdown.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
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,
|
||||
) -> 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) -> 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>) -> 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)
|
||||
.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)
|
||||
.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)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("b", "Python is slow", MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("c", "Rust and safety", MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = mem.recall("Rust", 10).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)
|
||||
.await
|
||||
.unwrap();
|
||||
let results = mem.recall("javascript", 10).await.unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn markdown_count() {
|
||||
let (_tmp, mem) = temp_workspace();
|
||||
mem.store("a", "first", MemoryCategory::Core).await.unwrap();
|
||||
mem.store("b", "second", MemoryCategory::Core)
|
||||
.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)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("b", "daily note", MemoryCategory::Daily)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let core = mem.list(Some(&MemoryCategory::Core)).await.unwrap();
|
||||
assert!(core.iter().all(|e| e.category == MemoryCategory::Core));
|
||||
|
||||
let daily = mem.list(Some(&MemoryCategory::Daily)).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)
|
||||
.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).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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue