Workspace & Memory System
Inspired by OpenClaw, the workspace provides persistent memory for agents with a flexible filesystem-like structure.
Key Principles
- "Memory is database, not RAM" - If you want to remember something, write it explicitly
- Flexible structure - Create any directory/file hierarchy you need
- Self-documenting - Use README.md files to describe directory structure
- Hybrid search - Combines FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion
Filesystem Structure
workspace/
├── README.md <- Root runbook/index
├── MEMORY.md <- Long-term curated memory
├── HEARTBEAT.md <- Periodic checklist
├── IDENTITY.md <- Agent name, nature, vibe
├── SOUL.md <- Core values
├── AGENTS.md <- Behavior instructions
├── USER.md <- User context
├── TOOLS.md <- Environment-specific tool notes
├── BOOTSTRAP.md <- First-run ritual (deleted after onboarding)
├── context/ <- Identity-related docs
│ ├── vision.md
│ └── priorities.md
├── daily/ <- Daily logs
│ ├── 2024-01-15.md
│ └── 2024-01-16.md
├── projects/ <- Arbitrary structure
│ └── alpha/
│ ├── README.md
│ └── notes.md
└── ...
Using the Workspace
use std::sync::Arc;
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
// Create workspace for a user (wraps embeddings in a default LRU cache)
let workspace = Workspace::new("user_123", pool)
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
// For tests: skip the cache layer (avoids unnecessary overhead with mocks)
// let workspace = Workspace::new("user_123", pool)
// .with_embeddings_uncached(Arc::new(MockEmbeddings::new(1536)));
// Read/write any path
let doc = workspace.read("projects/alpha/notes.md").await?;
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
workspace.append("daily/2024-01-15.md", "Completed task X").await?;
// Convenience methods for well-known files
workspace.append_memory("User prefers dark mode").await?;
workspace.append_daily_log("Session note").await?;
// List directory contents
let entries = workspace.list("projects/").await?;
// Search (hybrid FTS + vector)
let results = workspace.search("dark mode preference", 5).await?;
// Get system prompt from identity files
let prompt = workspace.system_prompt().await?;
Memory Tools
Four tools for LLM use:
memory_search- Hybrid search, MUST be called before answering questions about prior workmemory_write- Write to any path (memory, daily_log, or custom paths)memory_read- Read any file by pathmemory_tree- View workspace structure as a tree (depth parameter, default 1)
Hybrid Search (RRF)
Combines full-text search and vector similarity using Reciprocal Rank Fusion:
score(d) = Σ 1/(k + rank(d)) for each method where d appears
Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
Backend differences:
- PostgreSQL:
ts_rank_cdfor FTS, pgvector cosine distance for vectors, full RRF - libSQL: FTS5 for keyword search + vector search via
libsql_vector_idx(dimension set dynamically byensure_vector_index()during startup)
Multi-Scope Reads & Identity Isolation
When a workspace has additional read scopes (via with_additional_read_scopes), read operations can span multiple user scopes — a user with scopes ["alice", "shared"] can read documents from both.
Identity files are exempt from multi-scope reads. The system prompt reads identity and configuration files from the primary scope only (read_primary()), never from secondary scopes:
| File | Read method | Rationale |
|---|---|---|
| AGENTS.md | read_primary() |
Agent instructions are per-user |
| SOUL.md | read_primary() |
Core values are per-user |
| USER.md | read_primary() |
User context is per-user |
| IDENTITY.md | read_primary() |
Identity is per-user |
| TOOLS.md | read_primary() |
Tool config is per-user |
| BOOTSTRAP.md | read_primary() |
Onboarding is per-user |
| MEMORY.md | read() |
Shared memory is a feature |
| daily/*.md | read() |
Shared daily logs are a feature |
Why: Without this, a user with read access to another scope could silently inherit that scope's identity if their own copy is missing. The agent would present itself as the wrong user — a correctness and security issue.
Design rule: If you want shared identity across users, seed the same content into each user's scope at setup time. Don't rely on multi-scope fallback for identity files.
Heartbeat System
Proactive periodic execution (default: 30 minutes):
- Reads
HEARTBEAT.mdchecklist - Runs agent turn with checklist prompt
- If findings, notifies via channel
- If nothing, agent replies "HEARTBEAT_OK" (no notification)
use crate::agent::{HeartbeatConfig, spawn_heartbeat};
let config = HeartbeatConfig::default()
.with_interval(Duration::from_secs(60 * 30))
.with_notify("user_123", "telegram");
spawn_heartbeat(config, workspace, llm, response_tx);
Chunking Strategy
Documents are chunked for search indexing:
- Default: 800 words per chunk (roughly 800 tokens for English)
- 15% overlap between chunks for context preservation
- Minimum chunk size: 50 words (tiny trailing chunks merge with previous)