Add workspace and memory system (OpenClaw-inspired)

Implements persistent memory for agents with hybrid search:

- Database-backed workspace with PostgreSQL (not filesystem)
- Memory documents: MEMORY.md, daily logs, identity files
- Chunked content with FTS (tsvector) + vector (pgvector) indexes
- Reciprocal Rank Fusion (RRF) for hybrid search combining BM25 and semantic
- Memory tools: memory_search, memory_write, memory_read
- Proactive heartbeat system for periodic execution (30 min default)
- OpenAI embeddings provider (text-embedding-3-small)

Key patterns from OpenClaw:
- "Memory is files, not RAM" - explicit persistence required
- Two-tier memory: daily logs (raw) + curated MEMORY.md
- Session isolation via user_id/agent_id scoping

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-02 21:18:47 -08:00
co-authored by Claude Opus 4.5
parent 8c38566378
commit 4e238e60ac
15 changed files with 2999 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
//! Memory tools for persistent workspace memory.
//!
//! These tools allow the agent to:
//! - Search past memories, decisions, and context
//! - Write important information to long-term memory
//!
//! # Usage
//!
//! The agent should use `memory_search` before answering questions about
//! prior work, decisions, dates, people, preferences, or todos.
//!
//! Use `memory_write` to persist important facts that should be remembered
//! across sessions.
use std::sync::Arc;
use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::workspace::Workspace;
/// Tool for searching workspace memory.
///
/// Performs hybrid search (FTS + semantic) across all memory documents.
/// The agent should call this tool before answering questions about
/// prior work, decisions, preferences, or any historical context.
pub struct MemorySearchTool {
workspace: Arc<Workspace>,
}
impl MemorySearchTool {
/// Create a new memory search tool.
pub fn new(workspace: Arc<Workspace>) -> Self {
Self { workspace }
}
}
#[async_trait]
impl Tool for MemorySearchTool {
fn name(&self) -> &str {
"memory_search"
}
fn description(&self) -> &str {
"Search past memories, decisions, and context. MUST be called before answering \
questions about prior work, decisions, dates, people, preferences, or todos. \
Returns relevant snippets with relevance scores."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query. Use natural language to describe what you're looking for."
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default: 5, max: 20)",
"default": 5,
"minimum": 1,
"maximum": 20
}
},
"required": ["query"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let query = params
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'query' parameter".to_string()))?;
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(5)
.min(20) as usize;
let results = self
.workspace
.search(query, limit)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Search failed: {}", e)))?;
let output = serde_json::json!({
"query": query,
"results": results.iter().map(|r| serde_json::json!({
"content": r.content,
"score": r.score,
"document_id": r.document_id.to_string(),
"is_hybrid_match": r.is_hybrid(),
})).collect::<Vec<_>>(),
"result_count": results.len(),
});
Ok(ToolOutput::success(output, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false // Internal memory, trusted content
}
}
/// Tool for writing to workspace memory.
///
/// Use this to persist important information that should be remembered
/// across sessions: decisions, preferences, facts, lessons learned.
pub struct MemoryWriteTool {
workspace: Arc<Workspace>,
}
impl MemoryWriteTool {
/// Create a new memory write tool.
pub fn new(workspace: Arc<Workspace>) -> Self {
Self { workspace }
}
}
#[async_trait]
impl Tool for MemoryWriteTool {
fn name(&self) -> &str {
"memory_write"
}
fn description(&self) -> &str {
"Write to persistent memory. Use for important facts, decisions, preferences, \
or lessons learned that should be remembered across sessions. Use 'memory' target \
for curated long-term facts, 'daily_log' for timestamped session notes."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The content to write to memory. Be concise but include relevant context."
},
"target": {
"type": "string",
"enum": ["memory", "daily_log"],
"description": "Where to write: 'memory' for long-term curated facts, 'daily_log' for timestamped session notes",
"default": "daily_log"
}
},
"required": ["content"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'content' parameter".to_string())
})?;
if content.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"content cannot be empty".to_string(),
));
}
let target = params
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("daily_log");
match target {
"memory" => {
self.workspace
.append_memory(content)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
}
"daily_log" => {
self.workspace
.append_daily_log(content)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"invalid target '{}', must be 'memory' or 'daily_log'",
target
)));
}
}
let output = serde_json::json!({
"status": "written",
"target": target,
"content_length": content.len(),
});
Ok(ToolOutput::success(output, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false // Internal tool
}
}
/// Tool for reading specific memory documents.
///
/// Use this to read the full content of identity files, heartbeat checklist,
/// or other specific documents.
pub struct MemoryReadTool {
workspace: Arc<Workspace>,
}
impl MemoryReadTool {
/// Create a new memory read tool.
pub fn new(workspace: Arc<Workspace>) -> Self {
Self { workspace }
}
}
#[async_trait]
impl Tool for MemoryReadTool {
fn name(&self) -> &str {
"memory_read"
}
fn description(&self) -> &str {
"Read a specific memory document by type. Use this to read identity files, \
heartbeat checklist, or full memory document content."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"doc_type": {
"type": "string",
"enum": ["memory", "daily_log", "identity", "soul", "agents", "user", "heartbeat"],
"description": "The type of document to read"
},
"title": {
"type": "string",
"description": "Optional title (required for daily_log, format: YYYY-MM-DD)"
}
},
"required": ["doc_type"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let doc_type_str = params
.get("doc_type")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'doc_type' parameter".to_string())
})?;
let doc_type = crate::workspace::DocType::try_from(doc_type_str)
.map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
let title = params.get("title").and_then(|v| v.as_str());
let doc = self
.workspace
.get_document(doc_type, title)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Read failed: {}", e)))?;
let output = serde_json::json!({
"doc_type": doc_type_str,
"title": doc.title,
"content": doc.content,
"word_count": doc.word_count(),
"updated_at": doc.updated_at.to_rfc3339(),
});
Ok(ToolOutput::success(output, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false // Internal memory
}
}
#[cfg(test)]
mod tests {
use super::*;
// Integration tests would require a database connection.
// Unit tests for parameter validation:
#[test]
fn test_memory_search_schema() {
let workspace = Arc::new(Workspace::new(
"test_user",
deadpool_postgres::Pool::builder(deadpool_postgres::Manager::new(
tokio_postgres::Config::new(),
tokio_postgres::NoTls,
))
.build()
.unwrap(),
));
let tool = MemorySearchTool::new(workspace);
assert_eq!(tool.name(), "memory_search");
assert!(!tool.requires_sanitization());
let schema = tool.parameters_schema();
assert!(schema["properties"]["query"].is_object());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&"query".into())
);
}
#[test]
fn test_memory_write_schema() {
let workspace = Arc::new(Workspace::new(
"test_user",
deadpool_postgres::Pool::builder(deadpool_postgres::Manager::new(
tokio_postgres::Config::new(),
tokio_postgres::NoTls,
))
.build()
.unwrap(),
));
let tool = MemoryWriteTool::new(workspace);
assert_eq!(tool.name(), "memory_write");
let schema = tool.parameters_schema();
assert!(schema["properties"]["content"].is_object());
assert!(
schema["properties"]["target"]["enum"]
.as_array()
.unwrap()
.contains(&"memory".into())
);
}
}
+2
View File
@@ -5,6 +5,7 @@ mod ecommerce;
mod http;
mod json;
mod marketplace;
mod memory;
mod restaurant;
mod taskrabbit;
mod time;
@@ -14,6 +15,7 @@ pub use ecommerce::EcommerceTool;
pub use http::HttpTool;
pub use json::JsonTool;
pub use marketplace::MarketplaceTool;
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryWriteTool};
pub use restaurant::RestaurantTool;
pub use taskrabbit::TaskRabbitTool;
pub use time::TimeTool;