Files
optimclaw/src/workspace/mod.rs
T
5df0d13b59 Bump MSRV to 1.92, add GCP deployment files (#40)
* Bump MSRV to 1.92 and add GCP deployment files

rig-core 0.30 uses let_chains (stabilized post-1.87), which breaks
builds on Rust 1.85. Bump rust-version in Cargo.toml and both
Dockerfiles to 1.92 (verified working).

Add cloud deployment scaffolding:
- Dockerfile: multi-stage build for the main agent container
- deploy/cloud-sql-proxy.service: systemd unit for Cloud SQL Auth Proxy
- deploy/ironclaw.service: systemd unit for the IronClaw container
- deploy/setup.sh: VM bootstrap script (Docker, proxy, services)
- deploy/env.example: reference environment configuration

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Address review feedback: harden deploy scaffolding

- Add comment explaining GATEWAY_HOST=0.0.0.0 and when to use 127.0.0.1
- Document /opt/ironclaw ownership model (root-owned, Docker reads as root)
- Switch cloud-sql-proxy service from User=root to DynamicUser=yes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Resolve clippy lints (Rust 1.93) and fix CI test workflow

- Fix 97 collapsible_if warnings using let-chains syntax (auto-fixed)
- Fix ptr_arg: change &PathBuf to &Path in pairing store functions
- Fix suspicious_open_options: add .truncate(false) to OpenOptions
- Fix too_many_arguments: add clippy allow on execute_status
- Fix unnecessary_unwrap: use if-let in repository.rs hybrid_search
- Gate unused EchoTool with #[cfg(test)]
- Add PairingStore argument to ChannelStoreData::new() test call sites
- Add skip guard for bundled channel test when WASM artifacts unavailable
- Split CI test workflow to exclude PostgreSQL-dependent integration tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Address review feedback from ilblackdragon

- Add root check to setup.sh (exits with error if not root)
- Add warning comment to env.example about placeholder passwords
- Dockerfile.worker already uses rust:1.92 (no change needed)
- PR #41 overlap noted; will rebase after #41 merges

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: resolve 47 collapsible_if clippy warnings

Collapse nested if statements across the codebase to satisfy
clippy::collapsible_if on Rust 1.93.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-13 22:21:50 +04:00

615 lines
21 KiB
Rust

//! Workspace and memory system (OpenClaw-inspired).
//!
//! The workspace provides persistent memory for agents with a flexible
//! filesystem-like structure. Agents can create arbitrary markdown file
//! hierarchies that get indexed for full-text and semantic search.
//!
//! # Filesystem-like API
//!
//! ```text
//! workspace/
//! ├── README.md <- Root runbook/index
//! ├── MEMORY.md <- Long-term curated memory
//! ├── HEARTBEAT.md <- Periodic checklist
//! ├── context/ <- Identity and context
//! │ ├── vision.md
//! │ └── priorities.md
//! ├── daily/ <- Daily logs
//! │ ├── 2024-01-15.md
//! │ └── 2024-01-16.md
//! ├── projects/ <- Arbitrary structure
//! │ └── alpha/
//! │ ├── README.md
//! │ └── notes.md
//! └── ...
//! ```
//!
//! # Key Operations
//!
//! - `read(path)` - Read a file
//! - `write(path, content)` - Create or update a file
//! - `append(path, content)` - Append to a file
//! - `list(dir)` - List directory contents
//! - `delete(path)` - Delete a file
//! - `search(query)` - Full-text + semantic search across all files
//!
//! # Key Patterns
//!
//! 1. **Memory is persistence**: If you want to remember something, write it
//! 2. **Flexible structure**: Create any directory/file hierarchy you need
//! 3. **Self-documenting**: Use README.md files to describe directory structure
//! 4. **Hybrid search**: Vector similarity + BM25 full-text via RRF
mod chunker;
mod document;
mod embeddings;
mod repository;
mod search;
pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use embeddings::{EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OpenAiEmbeddings};
pub use repository::Repository;
pub use search::{SearchConfig, SearchResult};
use std::sync::Arc;
use chrono::{NaiveDate, Utc};
use deadpool_postgres::Pool;
use uuid::Uuid;
use crate::error::WorkspaceError;
/// Default template seeded into HEARTBEAT.md on first access.
///
/// Intentionally comment-only so the heartbeat runner treats it as
/// "effectively empty" and skips the LLM call until the user adds
/// real tasks.
const HEARTBEAT_SEED: &str = "\
# Heartbeat Checklist
<!-- Keep this file empty to skip heartbeat API calls.
Add tasks below when you want the agent to check something periodically.
Example:
- [ ] Check for unread emails needing a reply
- [ ] Review today's calendar for upcoming meetings
- [ ] Check CI build status for main branch
-->";
/// Workspace provides database-backed memory storage for an agent.
///
/// Each workspace is scoped to a user (and optionally an agent).
/// Documents are persisted to PostgreSQL and indexed for search.
pub struct Workspace {
/// User identifier (from channel).
user_id: String,
/// Optional agent ID for multi-agent isolation.
agent_id: Option<Uuid>,
/// Database repository.
repo: Repository,
/// Embedding provider for semantic search.
embeddings: Option<Arc<dyn EmbeddingProvider>>,
}
impl Workspace {
/// Create a new workspace for a user.
pub fn new(user_id: impl Into<String>, pool: Pool) -> Self {
Self {
user_id: user_id.into(),
agent_id: None,
repo: Repository::new(pool),
embeddings: None,
}
}
/// Create a workspace with a specific agent ID.
pub fn with_agent(mut self, agent_id: Uuid) -> Self {
self.agent_id = Some(agent_id);
self
}
/// Set the embedding provider for semantic search.
pub fn with_embeddings(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
self.embeddings = Some(provider);
self
}
/// Get the user ID.
pub fn user_id(&self) -> &str {
&self.user_id
}
/// Get the agent ID.
pub fn agent_id(&self) -> Option<Uuid> {
self.agent_id
}
// ==================== File Operations ====================
/// Read a file by path.
///
/// Returns the document if it exists, or an error if not found.
///
/// # Example
/// ```ignore
/// let doc = workspace.read("context/vision.md").await?;
/// println!("{}", doc.content);
/// ```
pub async fn read(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
let path = normalize_path(path);
self.repo
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
}
/// Write (create or update) a file.
///
/// Creates parent directories implicitly (they're virtual in the DB).
/// Re-indexes the document for search after writing.
///
/// # Example
/// ```ignore
/// workspace.write("projects/alpha/README.md", "# Project Alpha\n\nDescription here.").await?;
/// ```
pub async fn write(&self, path: &str, content: &str) -> Result<MemoryDocument, WorkspaceError> {
let path = normalize_path(path);
let doc = self
.repo
.get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
.await?;
self.repo.update_document(doc.id, content).await?;
self.reindex_document(doc.id).await?;
// Return updated doc
self.repo.get_document_by_id(doc.id).await
}
/// Append content to a file.
///
/// Creates the file if it doesn't exist.
/// Adds a newline separator between existing and new content.
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
let path = normalize_path(path);
let doc = self
.repo
.get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
.await?;
let new_content = if doc.content.is_empty() {
content.to_string()
} else {
format!("{}\n{}", doc.content, content)
};
self.repo.update_document(doc.id, &new_content).await?;
self.reindex_document(doc.id).await?;
Ok(())
}
/// Check if a file exists.
pub async fn exists(&self, path: &str) -> Result<bool, WorkspaceError> {
let path = normalize_path(path);
match self
.repo
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
{
Ok(_) => Ok(true),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false),
Err(e) => Err(e),
}
}
/// Delete a file.
///
/// Also deletes associated chunks.
pub async fn delete(&self, path: &str) -> Result<(), WorkspaceError> {
let path = normalize_path(path);
self.repo
.delete_document_by_path(&self.user_id, self.agent_id, &path)
.await
}
/// List files and directories in a path.
///
/// Returns immediate children (not recursive).
/// Use empty string or "/" for root directory.
///
/// # Example
/// ```ignore
/// let entries = workspace.list("projects/").await?;
/// for entry in entries {
/// if entry.is_directory {
/// println!("📁 {}/", entry.name());
/// } else {
/// println!("📄 {}", entry.name());
/// }
/// }
/// ```
pub async fn list(&self, directory: &str) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let directory = normalize_directory(directory);
self.repo
.list_directory(&self.user_id, self.agent_id, &directory)
.await
}
/// List all files recursively (flat list of all paths).
pub async fn list_all(&self) -> Result<Vec<String>, WorkspaceError> {
self.repo.list_all_paths(&self.user_id, self.agent_id).await
}
// ==================== Convenience Methods ====================
/// Get the main MEMORY.md document (long-term curated memory).
///
/// Creates it if it doesn't exist.
pub async fn memory(&self) -> Result<MemoryDocument, WorkspaceError> {
self.read_or_create(paths::MEMORY).await
}
/// Get today's daily log.
///
/// Daily logs are append-only and keyed by date.
pub async fn today_log(&self) -> Result<MemoryDocument, WorkspaceError> {
let today = Utc::now().date_naive();
self.daily_log(today).await
}
/// Get a daily log for a specific date.
pub async fn daily_log(&self, date: NaiveDate) -> Result<MemoryDocument, WorkspaceError> {
let path = format!("daily/{}.md", date.format("%Y-%m-%d"));
self.read_or_create(&path).await
}
/// Get the heartbeat checklist (HEARTBEAT.md).
///
/// Returns the DB-stored checklist if it exists, otherwise falls back
/// to the in-memory seed template. The seed is never written to the
/// database; the user creates the real file via `memory_write` when
/// they actually want periodic checks. The seed content is all HTML
/// comments, which the heartbeat runner treats as "effectively empty"
/// and skips the LLM call.
pub async fn heartbeat_checklist(&self) -> Result<Option<String>, WorkspaceError> {
match self.read(paths::HEARTBEAT).await {
Ok(doc) => Ok(Some(doc.content)),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())),
Err(e) => Err(e),
}
}
/// Helper to read or create a file.
async fn read_or_create(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
self.repo
.get_or_create_document_by_path(&self.user_id, self.agent_id, path)
.await
}
// ==================== Memory Operations ====================
/// Append an entry to the main MEMORY.md document.
///
/// This is for important facts, decisions, and preferences worth
/// remembering long-term.
pub async fn append_memory(&self, entry: &str) -> Result<(), WorkspaceError> {
// Use double newline for memory entries (semantic separation)
let doc = self.memory().await?;
let new_content = if doc.content.is_empty() {
entry.to_string()
} else {
format!("{}\n\n{}", doc.content, entry)
};
self.repo.update_document(doc.id, &new_content).await?;
self.reindex_document(doc.id).await?;
Ok(())
}
/// Append an entry to today's daily log.
///
/// Daily logs are raw, append-only notes for the current day.
pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> {
let today = Utc::now().date_naive();
let path = format!("daily/{}.md", today.format("%Y-%m-%d"));
let timestamp = Utc::now().format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await
}
// ==================== System Prompt ====================
/// Build the system prompt from identity files.
///
/// Loads AGENTS.md, SOUL.md, USER.md, and IDENTITY.md to compose
/// the agent's system prompt.
pub async fn system_prompt(&self) -> Result<String, WorkspaceError> {
let mut parts = Vec::new();
// Load identity files in order of importance
let identity_files = [
(paths::AGENTS, "## Agent Instructions"),
(paths::SOUL, "## Core Values"),
(paths::USER, "## User Context"),
(paths::IDENTITY, "## Identity"),
];
for (path, header) in identity_files {
if let Ok(doc) = self.read(path).await
&& !doc.content.is_empty()
{
parts.push(format!("{}\n\n{}", header, doc.content));
}
}
// Add today's memory context (last 2 days of daily logs)
let today = Utc::now().date_naive();
let yesterday = today.pred_opt().unwrap_or(today);
for date in [today, yesterday] {
if let Ok(doc) = self.daily_log(date).await
&& !doc.content.is_empty()
{
let header = if date == today {
"## Today's Notes"
} else {
"## Yesterday's Notes"
};
parts.push(format!("{}\n\n{}", header, doc.content));
}
}
Ok(parts.join("\n\n---\n\n"))
}
// ==================== Search ====================
/// Hybrid search across all memory documents.
///
/// Combines full-text search (BM25) with semantic search (vector similarity)
/// using Reciprocal Rank Fusion (RRF).
pub async fn search(
&self,
query: &str,
limit: usize,
) -> Result<Vec<SearchResult>, WorkspaceError> {
self.search_with_config(query, SearchConfig::default().with_limit(limit))
.await
}
/// Search with custom configuration.
pub async fn search_with_config(
&self,
query: &str,
config: SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
// Generate embedding for semantic search if provider available
let embedding = if let Some(ref provider) = self.embeddings {
Some(
provider
.embed(query)
.await
.map_err(|e| WorkspaceError::EmbeddingFailed {
reason: e.to_string(),
})?,
)
} else {
None
};
self.repo
.hybrid_search(
&self.user_id,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await
}
// ==================== Indexing ====================
/// Re-index a document (chunk and generate embeddings).
async fn reindex_document(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
// Get the document
let doc = self.repo.get_document_by_id(document_id).await?;
// Chunk the content
let chunks = chunk_document(&doc.content, ChunkConfig::default());
// Delete old chunks
self.repo.delete_chunks(document_id).await?;
// Insert new chunks
for (index, content) in chunks.into_iter().enumerate() {
// Generate embedding if provider available
let embedding = if let Some(ref provider) = self.embeddings {
match provider.embed(&content).await {
Ok(emb) => Some(emb),
Err(e) => {
tracing::warn!("Failed to generate embedding: {}", e);
None
}
}
} else {
None
};
self.repo
.insert_chunk(document_id, index as i32, &content, embedding.as_deref())
.await?;
}
Ok(())
}
// ==================== Seeding ====================
/// Seed any missing core identity files in the workspace.
///
/// Called on every boot. Only creates files that don't already exist,
/// so user edits are never overwritten. Returns the number of files
/// created (0 if all core files already existed).
pub async fn seed_if_empty(&self) -> Result<usize, WorkspaceError> {
let seed_files: &[(&str, &str)] = &[
(
paths::README,
"# Workspace\n\n\
This is your agent's persistent memory. Files here are indexed for search\n\
and used to build the agent's context.\n\n\
## Structure\n\n\
- `MEMORY.md` - Long-term notes and facts worth remembering\n\
- `IDENTITY.md` - Agent name, nature, personality\n\
- `SOUL.md` - Core values and principles\n\
- `AGENTS.md` - Behavior instructions for the agent\n\
- `USER.md` - Information about you (the user)\n\
- `HEARTBEAT.md` - Periodic background task checklist\n\
- `daily/` - Automatic daily session logs\n\
- `context/` - Additional context documents\n\n\
Edit these files to shape how your agent thinks and acts.",
),
(
paths::MEMORY,
"# Memory\n\n\
Long-term notes, decisions, and facts worth remembering.\n\
The agent appends here during conversations.",
),
(
paths::IDENTITY,
"# Identity\n\n\
Name: IronClaw\n\
Nature: A secure personal AI assistant\n\n\
Edit this file to give your agent a custom name and personality.",
),
(
paths::SOUL,
"# Core Values\n\n\
- Protect user privacy and data security above all else\n\
- Be honest about limitations and uncertainty\n\
- Prefer action over lengthy deliberation\n\
- Ask for clarification rather than guessing on important decisions\n\
- Learn from mistakes and remember lessons",
),
(
paths::AGENTS,
"# Agent Instructions\n\n\
You are a personal AI assistant with access to tools and persistent memory.\n\n\
## Guidelines\n\n\
- Always search memory before answering questions about prior conversations\n\
- Write important facts and decisions to memory for future reference\n\
- Use the daily log for session-level notes\n\
- Be concise but thorough",
),
(
paths::USER,
"# User Context\n\n\
The agent will fill this in as it learns about you.\n\
You can also edit this directly to provide context upfront.",
),
(paths::HEARTBEAT, HEARTBEAT_SEED),
];
let mut count = 0;
for (path, content) in seed_files {
// Skip files that already exist (never overwrite user edits)
match self.read(path).await {
Ok(_) => continue,
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => {
tracing::warn!("Failed to check {}: {}", path, e);
continue;
}
}
if let Err(e) = self.write(path, content).await {
tracing::warn!("Failed to seed {}: {}", path, e);
} else {
count += 1;
}
}
if count > 0 {
tracing::info!("Seeded {} workspace files", count);
}
Ok(count)
}
/// Generate embeddings for chunks that don't have them yet.
///
/// This is useful for backfilling embeddings after enabling the provider.
pub async fn backfill_embeddings(&self) -> Result<usize, WorkspaceError> {
let Some(ref provider) = self.embeddings else {
return Ok(0);
};
let chunks = self
.repo
.get_chunks_without_embeddings(&self.user_id, self.agent_id, 100)
.await?;
let mut count = 0;
for chunk in chunks {
match provider.embed(&chunk.content).await {
Ok(embedding) => {
self.repo
.update_chunk_embedding(chunk.id, &embedding)
.await?;
count += 1;
}
Err(e) => {
tracing::warn!("Failed to embed chunk {}: {}", chunk.id, e);
}
}
}
Ok(count)
}
}
/// Normalize a file path (remove leading/trailing slashes, collapse //).
fn normalize_path(path: &str) -> String {
let path = path.trim().trim_matches('/');
// Collapse multiple slashes
let mut result = String::new();
let mut last_was_slash = false;
for c in path.chars() {
if c == '/' {
if !last_was_slash {
result.push(c);
}
last_was_slash = true;
} else {
result.push(c);
last_was_slash = false;
}
}
result
}
/// Normalize a directory path (ensure no trailing slash for consistency).
fn normalize_directory(path: &str) -> String {
let path = normalize_path(path);
path.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path() {
assert_eq!(normalize_path("foo/bar"), "foo/bar");
assert_eq!(normalize_path("/foo/bar/"), "foo/bar");
assert_eq!(normalize_path("foo//bar"), "foo/bar");
assert_eq!(normalize_path(" /foo/ "), "foo");
assert_eq!(normalize_path("README.md"), "README.md");
}
#[test]
fn test_normalize_directory() {
assert_eq!(normalize_directory("foo/bar/"), "foo/bar");
assert_eq!(normalize_directory("foo/bar"), "foo/bar");
assert_eq!(normalize_directory("/"), "");
assert_eq!(normalize_directory(""), "");
}
}