mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 01:19:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
8c38566378
commit
4e238e60ac
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user