From 5f841554d58d1d588f05e666ba1d74a0aa92de4e Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 2 Mar 2026 19:00:21 -0800 Subject: [PATCH] feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/app.rs | 22 +++++ src/error.rs | 3 + src/tools/builtin/memory.rs | 34 +++++-- src/workspace/README.md | 2 + src/workspace/document.rs | 4 + src/workspace/mod.rs | 182 ++++++++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index eb2d4482..d0cf4b09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -672,6 +672,28 @@ impl AppBuilder { } } + // Import workspace files from disk if WORKSPACE_IMPORT_DIR is set. + // This lets Docker images / deployment scripts ship customized + // workspace templates (e.g., AGENTS.md, TOOLS.md) that override + // the generic seeds. Only imports files that don't already exist + // in the database — never overwrites user edits. + if let Ok(import_dir) = std::env::var("WORKSPACE_IMPORT_DIR") { + let import_path = std::path::Path::new(&import_dir); + match ws.import_from_directory(import_path).await { + Ok(count) if count > 0 => { + tracing::info!("Imported {} workspace file(s) from {}", count, import_dir); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + "Failed to import workspace files from {}: {}", + import_dir, + e + ); + } + } + } + if embeddings.is_some() { let ws_bg = Arc::clone(ws); tokio::spawn(async move { diff --git a/src/error.rs b/src/error.rs index c1d0072d..4c746122 100644 --- a/src/error.rs +++ b/src/error.rs @@ -331,6 +331,9 @@ pub enum WorkspaceError { #[error("Heartbeat error: {reason}")] HeartbeatError { reason: String }, + + #[error("I/O error: {reason}")] + IoError { reason: String }, } /// Orchestrator errors (internal API, container management). diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index ea48da70..ac768402 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -140,7 +140,8 @@ impl Tool for MemoryWriteTool { Use for important facts, decisions, preferences, or lessons learned that should \ be remembered across sessions. Targets: 'memory' for curated long-term facts, \ 'daily_log' for timestamped session notes, 'heartbeat' for the periodic \ - checklist (HEARTBEAT.md), or provide a custom path for arbitrary file creation." + checklist (HEARTBEAT.md), 'bootstrap' to clear the first-run ritual file, \ + or provide a custom path for arbitrary file creation." } fn parameters_schema(&self) -> serde_json::Value { @@ -153,7 +154,7 @@ impl Tool for MemoryWriteTool { }, "target": { "type": "string", - "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, or a path like 'projects/alpha/notes.md'", + "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, 'bootstrap' to clear BOOTSTRAP.md (content is ignored; the file is always cleared), or a path like 'projects/alpha/notes.md'", "default": "daily_log" }, "append": { @@ -175,17 +176,36 @@ impl Tool for MemoryWriteTool { let content = require_str(¶ms, "content")?; + let target = params + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("daily_log"); + + // Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete. + // Handled early because it accepts empty content (unlike other targets). + if target == "bootstrap" { + // Write empty content to effectively disable the bootstrap injection. + // system_prompt_for_context() skips empty files. + self.workspace + .write(paths::BOOTSTRAP, "") + .await + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; + + let output = serde_json::json!({ + "status": "cleared", + "path": paths::BOOTSTRAP, + "message": "BOOTSTRAP.md cleared. First-run ritual will not repeat.", + }); + + return Ok(ToolOutput::success(output, start.elapsed())); + } + 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"); - // Reject writes to identity files that are loaded into the system prompt. // An attacker could use prompt injection to trick the agent into overwriting // these, poisoning future conversations. diff --git a/src/workspace/README.md b/src/workspace/README.md index 4768acf4..2b3ee5b4 100644 --- a/src/workspace/README.md +++ b/src/workspace/README.md @@ -20,6 +20,8 @@ workspace/ ├── 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 diff --git a/src/workspace/document.rs b/src/workspace/document.rs index 23dcd5b2..354c7175 100644 --- a/src/workspace/document.rs +++ b/src/workspace/document.rs @@ -27,6 +27,10 @@ pub mod paths { 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. diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index c898a7d1..6196b3f1 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -271,6 +271,53 @@ const HEARTBEAT_SEED: &str = "\ - Clean up context/ documents that are outdated -->"; +/// Default template seeded into TOOLS.md on first access. +/// +/// TOOLS.md does not control tool availability; it is user guidance +/// for how to use external tools. The agent may update this file as it +/// learns environment-specific details (SSH hostnames, device names, etc.). +const TOOLS_SEED: &str = "\ +"; + +/// First-run ritual seeded into BOOTSTRAP.md on initial workspace setup. +/// +/// The agent reads this file at the start of every session when it exists. +/// After completing the ritual the agent must delete this file so it is +/// never repeated. It is NOT a protected file; the agent needs write access. +const BOOTSTRAP_SEED: &str = "\ +# Bootstrap + +You are starting up for the first time. Follow these steps before anything else. + +## Steps + +1. **Say hello.** Greet the user warmly and introduce yourself briefly. +2. **Get to know the user.** Ask a few questions to understand who they are, \ +what they work on, and what they want from an AI assistant. Take notes. +3. **Save what you learned.** + - Write any environment-specific tool details the user mentions to `TOOLS.md` \ +using `memory_write` with target set to the path. + - Write a summary of the conversation and key facts to `MEMORY.md` \ +using `memory_write` with target `memory`. + - Note: `USER.md`, `IDENTITY.md`, `SOUL.md`, and `AGENTS.md` are protected \ +from tool writes for security. Tell the user what you'd suggest for those files \ +so they can edit them directly. +4. **Delete this file.** When onboarding is complete, use `memory_write` with \ +target `bootstrap` to clear this file so setup never repeats. + +Keep the conversation natural. Do not read these steps aloud. +"; + /// Workspace provides database-backed memory storage for an agent. /// /// Each workspace is scoped to a user (and optionally an agent). @@ -547,6 +594,24 @@ impl Workspace { ) -> Result { let mut parts = Vec::new(); + // Bootstrap ritual: inject FIRST when present (first-run only). + // The agent must complete the ritual and then delete this file. + // + // Note: BOOTSTRAP.md is intentionally NOT write-protected so the agent + // can delete it after onboarding. This means a prompt injection attack + // could write to it, but the file is only injected on the next session + // (not the current one), limiting the blast radius. + if let Ok(doc) = self.read(paths::BOOTSTRAP).await + && !doc.content.is_empty() + { + parts.push(format!( + "## First-Run Bootstrap\n\n\ + A BOOTSTRAP.md file exists in the workspace. Read and follow it, \ + then delete it when done.\n\n{}", + doc.content + )); + } + // Load identity files in order of importance let identity_files = [ (paths::AGENTS, "## Agent Instructions"), @@ -563,6 +628,14 @@ impl Workspace { } } + // Tool notes: environment-specific guidance the agent or user has written. + // TOOLS.md does not control tool availability; it is guidance only. + if let Ok(doc) = self.read(paths::TOOLS).await + && !doc.content.is_empty() + { + parts.push(format!("## Tool Notes\n\n{}", doc.content)); + } + // Load MEMORY.md only in direct/main sessions (never group chats) if !is_group_chat && let Ok(doc) = self.read(paths::MEMORY).await @@ -693,6 +766,7 @@ impl Workspace { - `SOUL.md` - Core values and behavioral boundaries\n\ - `AGENTS.md` - Session routine and operational instructions\n\ - `USER.md` - Information about you (the user)\n\ + - `TOOLS.md` - Environment-specific tool notes\n\ - `HEARTBEAT.md` - Periodic background task checklist\n\ - `daily/` - Automatic daily session logs\n\ - `context/` - Additional context documents\n\n\ @@ -763,6 +837,7 @@ impl Workspace { You can also edit this directly to provide context upfront.", ), (paths::HEARTBEAT, HEARTBEAT_SEED), + (paths::TOOLS, TOOLS_SEED), ]; let mut count = 0; @@ -784,12 +859,119 @@ impl Workspace { } } + // BOOTSTRAP.md is only seeded on truly fresh workspaces (no identity + // files exist yet). This prevents existing users from getting a + // spurious first-run ritual after upgrading. + if self.read(paths::BOOTSTRAP).await.is_err() { + let (agents_res, soul_res, user_res) = tokio::join!( + self.read(paths::AGENTS), + self.read(paths::SOUL), + self.read(paths::USER), + ); + let is_fresh_workspace = + matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. })) + && matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. })) + && matches!(user_res, Err(WorkspaceError::DocumentNotFound { .. })); + + if is_fresh_workspace { + if let Err(e) = self.write(paths::BOOTSTRAP, BOOTSTRAP_SEED).await { + tracing::warn!("Failed to seed {}: {}", paths::BOOTSTRAP, e); + } else { + count += 1; + } + } + } + if count > 0 { tracing::info!("Seeded {} workspace files", count); } Ok(count) } + /// Import markdown files from a directory on disk into the workspace DB. + /// + /// Scans `dir` for `*.md` files (non-recursive) and writes each one into + /// the workspace **only if it doesn't already exist in the database**. + /// This allows Docker images or deployment scripts to ship customized + /// workspace templates that override the generic seeds. + /// + /// Returns the number of files imported (0 if all already existed). + pub async fn import_from_directory( + &self, + dir: &std::path::Path, + ) -> Result { + if !dir.is_dir() { + tracing::warn!( + "Workspace import directory does not exist: {}", + dir.display() + ); + return Ok(0); + } + + let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceError::IoError { + reason: format!("failed to read directory {}: {}", dir.display(), e), + })?; + + let mut count = 0; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + tracing::warn!("Failed to read directory entry in {}: {}", dir.display(), e); + continue; + } + }; + + let path = entry.path(); + // Only import .md files + if path.extension() != Some(std::ffi::OsStr::new("md")) { + continue; + } + + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + // Skip if already exists in DB (never overwrite user edits) + match self.read(file_name).await { + Ok(_) => continue, + Err(WorkspaceError::DocumentNotFound { .. }) => {} + Err(e) => { + tracing::warn!("Failed to check {}: {}", file_name, e); + continue; + } + } + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + tracing::warn!("Failed to read import file {}: {}", path.display(), e); + continue; + } + }; + + if content.trim().is_empty() { + continue; + } + + if let Err(e) = self.write(file_name, &content).await { + tracing::warn!("Failed to import {}: {}", file_name, e); + } else { + tracing::info!("Imported workspace file from disk: {}", file_name); + count += 1; + } + } + + if count > 0 { + tracing::info!( + "Imported {} workspace file(s) from {}", + count, + dir.display() + ); + } + Ok(count) + } + /// Generate embeddings for chunks that don't have them yet. /// /// This is useful for backfilling embeddings after enabling the provider.