Files
optimclaw/src/workspace/document.rs
T
5f841554d5 feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import (#477)
* feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import

Add two new OpenClaw-compatible workspace markdown files:

- TOOLS.md: Environment-specific tool notes (SSH hosts, device names,
  etc.) injected into the system prompt under "## Tool Notes". Seeded
  as comment-only (like HEARTBEAT.md) so it's effectively empty until
  the user adds real content. Not write-protected — the agent can
  update it as it learns the environment.

- BOOTSTRAP.md: First-run onboarding ritual. Injected FIRST in the
  system prompt when present. Guides the agent through introducing
  itself, learning about the user, and updating workspace files.
  Only seeded on truly fresh workspaces (no existing identity files)
  to avoid triggering the ritual on existing deployments. Agent clears
  it via `memory_write(target="bootstrap")` when done.

Add `Workspace::import_from_directory()` for disk-to-DB import:

- Scans a directory for *.md files and imports any that don't already
  exist in the database (never overwrites user edits)
- Controlled by WORKSPACE_IMPORT_DIR env var, runs after seed_if_empty()
- Enables Docker images / deployment scripts to ship customized
  workspace templates that override generic seeds
- Backwards compatible: no-op when env var is unset

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review comments

- Use stable `path.extension() != Some(OsStr::new("md"))` instead of
  unstable `is_none_or` (nightly-only)
- Use `tokio::join!` for concurrent DB reads in fresh-workspace check
- Skip unreadable directory entries instead of failing the entire import
- Skip unreadable files instead of failing the entire import

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-02 19:00:21 -08:00

226 lines
6.7 KiB
Rust

//! Memory document types for the workspace.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Well-known document paths.
///
/// These are conventional paths that have special meaning in the workspace.
/// Agents can create arbitrary paths beyond these.
pub mod paths {
/// Long-term curated memory.
pub const MEMORY: &str = "MEMORY.md";
/// Agent identity (name, nature, vibe).
pub const IDENTITY: &str = "IDENTITY.md";
/// Core values and principles.
pub const SOUL: &str = "SOUL.md";
/// Behavior instructions.
pub const AGENTS: &str = "AGENTS.md";
/// User context (name, preferences).
pub const USER: &str = "USER.md";
/// Periodic checklist for heartbeat.
pub const HEARTBEAT: &str = "HEARTBEAT.md";
/// Root runbook/readme.
pub const README: &str = "README.md";
/// Daily logs directory.
pub const DAILY_DIR: &str = "daily/";
/// Context directory (for identity-related docs).
pub const CONTEXT_DIR: &str = "context/";
/// User-editable notes for environment-specific tool guidance.
pub const TOOLS: &str = "TOOLS.md";
/// First-run ritual file; self-deletes after onboarding completes.
pub const BOOTSTRAP: &str = "BOOTSTRAP.md";
}
/// A memory document stored in the database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryDocument {
/// Unique document ID.
pub id: Uuid,
/// User identifier.
pub user_id: String,
/// Optional agent ID for multi-agent isolation.
pub agent_id: Option<Uuid>,
/// File path within the workspace (e.g., "context/vision.md").
pub path: String,
/// Full document content.
pub content: String,
/// Creation timestamp.
pub created_at: DateTime<Utc>,
/// Last update timestamp.
pub updated_at: DateTime<Utc>,
/// Flexible metadata.
pub metadata: serde_json::Value,
}
impl MemoryDocument {
/// Create a new document with a path.
pub fn new(
user_id: impl Into<String>,
agent_id: Option<Uuid>,
path: impl Into<String>,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
user_id: user_id.into(),
agent_id,
path: path.into(),
content: String::new(),
created_at: now,
updated_at: now,
metadata: serde_json::Value::Object(serde_json::Map::new()),
}
}
/// Get the file name from the path.
pub fn file_name(&self) -> &str {
self.path.rsplit('/').next().unwrap_or(&self.path)
}
/// Get the parent directory from the path.
pub fn parent_dir(&self) -> Option<&str> {
let idx = self.path.rfind('/')?;
Some(&self.path[..idx])
}
/// Check if the document is empty.
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
/// Get word count.
pub fn word_count(&self) -> usize {
self.content.split_whitespace().count()
}
/// Check if this is a well-known identity document.
pub fn is_identity_document(&self) -> bool {
matches!(
self.path.as_str(),
paths::IDENTITY | paths::SOUL | paths::AGENTS | paths::USER
)
}
}
/// An entry in a workspace directory listing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceEntry {
/// Path relative to listing directory.
pub path: String,
/// True if this is a directory (has children).
pub is_directory: bool,
/// Last update timestamp (latest among children for directories).
pub updated_at: Option<DateTime<Utc>>,
/// Preview of content (first ~200 chars, None for directories).
pub content_preview: Option<String>,
}
impl WorkspaceEntry {
/// Get the entry name (last path component).
pub fn name(&self) -> &str {
self.path.rsplit('/').next().unwrap_or(&self.path)
}
}
/// A chunk of a memory document for search indexing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryChunk {
/// Unique chunk ID.
pub id: Uuid,
/// Parent document ID.
pub document_id: Uuid,
/// Position in the document (0-based).
pub chunk_index: i32,
/// Chunk text content.
pub content: String,
/// Embedding vector (if generated).
pub embedding: Option<Vec<f32>>,
/// Creation timestamp.
pub created_at: DateTime<Utc>,
}
impl MemoryChunk {
/// Create a new chunk (not persisted yet).
pub fn new(document_id: Uuid, chunk_index: i32, content: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
document_id,
chunk_index,
content: content.into(),
embedding: None,
created_at: Utc::now(),
}
}
/// Set the embedding.
pub fn with_embedding(mut self, embedding: Vec<f32>) -> Self {
self.embedding = Some(embedding);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_document_new() {
let doc = MemoryDocument::new("user1", None, "context/vision.md");
assert_eq!(doc.user_id, "user1");
assert_eq!(doc.path, "context/vision.md");
assert!(doc.content.is_empty());
}
#[test]
fn test_memory_document_file_name() {
let doc = MemoryDocument::new("user1", None, "projects/alpha/README.md");
assert_eq!(doc.file_name(), "README.md");
}
#[test]
fn test_memory_document_parent_dir() {
let doc = MemoryDocument::new("user1", None, "projects/alpha/README.md");
assert_eq!(doc.parent_dir(), Some("projects/alpha"));
let root_doc = MemoryDocument::new("user1", None, "README.md");
assert_eq!(root_doc.parent_dir(), None);
}
#[test]
fn test_memory_document_word_count() {
let mut doc = MemoryDocument::new("user1", None, "MEMORY.md");
assert_eq!(doc.word_count(), 0);
doc.content = "Hello world, this is a test.".to_string();
assert_eq!(doc.word_count(), 6);
}
#[test]
fn test_is_identity_document() {
let identity = MemoryDocument::new("user1", None, paths::IDENTITY);
assert!(identity.is_identity_document());
let soul = MemoryDocument::new("user1", None, paths::SOUL);
assert!(soul.is_identity_document());
let memory = MemoryDocument::new("user1", None, paths::MEMORY);
assert!(!memory.is_identity_document());
let custom = MemoryDocument::new("user1", None, "projects/notes.md");
assert!(!custom.is_identity_document());
}
#[test]
fn test_workspace_entry_name() {
let entry = WorkspaceEntry {
path: "projects/alpha".to_string(),
is_directory: true,
updated_at: None,
content_preview: None,
};
assert_eq!(entry.name(), "alpha");
}
}